Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions lib/__tests__/json-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseJson5Compat,
diffLines,
validate,
singularize,
SAMPLE_JSON,
} from "../json-utils";

Expand Down Expand Up @@ -292,6 +293,90 @@ describe("toXML", () => {
expect(result.value).toContain(" <addressItem>x</addressItem>");
});

it("singularizes sibilant plurals (-ches, -shes, -xes, -zes)", () => {
const result = toXML(
'{"matches":["a"],"branches":["b"],"hashes":["c"],"boxes":["d"],"fixes":["e"],"quizzes":["f"]}',
);
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("<match>a</match>");
expect(result.value).toContain("<branch>b</branch>");
expect(result.value).toContain("<hash>c</hash>");
expect(result.value).toContain("<box>d</box>");
expect(result.value).toContain("<fix>e</fix>");
expect(result.value).toContain("<quiz>f</quiz>");
});

it("singularizes ambiguous -ses cases correctly", () => {
const result = toXML('{"cases":["x"],"addresses":["y"],"statuses":["z"]}');
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("<case>x</case>");
expect(result.value).toContain("<address>y</address>");
expect(result.value).toContain("<status>z</status>");
});

it("singularizes ambiguous -ses words based on stem endings", () => {
const stems = toXML(
'{"addresses":["a"],"classes":["b"],"statuses":["c"],"viruses":["d"],"bonuses":["e"],"campuses":["f"],"buses":["g"]}',
);
expect(stems.ok).toBe(true);
if (stems.ok) {
expect(stems.value).toContain("<address>a</address>");
expect(stems.value).toContain("<class>b</class>");
expect(stems.value).toContain("<status>c</status>");
expect(stems.value).toContain("<virus>d</virus>");
expect(stems.value).toContain("<bonus>e</bonus>");
expect(stems.value).toContain("<campus>f</campus>");
expect(stems.value).toContain("<bus>g</bus>");
}

const nonStems = toXML(
'{"cases":["a"],"causes":["b"],"houses":["c"],"responses":["d"],"courses":["e"],"releases":["f"],"licenses":["g"],"databases":["h"]}',
);
expect(nonStems.ok).toBe(true);
if (nonStems.ok) {
expect(nonStems.value).toContain("<case>a</case>");
expect(nonStems.value).toContain("<cause>b</cause>");
expect(nonStems.value).toContain("<house>c</house>");
expect(nonStems.value).toContain("<response>d</response>");
expect(nonStems.value).toContain("<course>e</course>");
expect(nonStems.value).toContain("<release>f</release>");
expect(nonStems.value).toContain("<license>g</license>");
expect(nonStems.value).toContain("<database>h</database>");
}
});

it("handles -zes plurals and caches appropriately in XML", () => {
const result = toXML(
'{"sizes":["s"],"prizes":["p"],"mazes":["m"],"quizzes":["q"],"caches":["c"]}',
);
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("<size>s</size>");
expect(result.value).toContain("<prize>p</prize>");
expect(result.value).toContain("<maze>m</maze>");
expect(result.value).toContain("<quiz>q</quiz>");
expect(result.value).toContain("<cach>c</cach>");
});

it("preserves existing singularization for -ies, simple -s, and non-plural keys", () => {
const result = toXML(
'{"categories":["a"],"users":["b"],"boss":["c"],"root":["d"],"tags":["e"]}',
);
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("<category>a</category>");
expect(result.value).toContain("<user>b</user>");
expect(result.value).toContain("<bossItem>c</bossItem>");
expect(result.value).toContain("<rootItem>d</rootItem>");
expect(result.value).toContain("<tag>e</tag>");
});

it("wraps a top-level array of arrays, each sub-array in its own element", () => {
const result = toXML("[[1,2],[3]]");
expect(result.ok).toBe(true);
Expand Down Expand Up @@ -765,6 +850,145 @@ describe("toTypeScript array items", () => {
expect(transpile.diagnostics ?? []).toHaveLength(0);
}
});

it("singularizes sibilant plural array names in interfaces", () => {
const result = toTypeScript('{"matches":[{"x":1}],"branches":[{"y":2}],"boxes":[{"z":3}]}');
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("export interface Match {");
expect(result.value).toContain("matches: Match[];");
expect(result.value).toContain("export interface Branch {");
expect(result.value).toContain("branches: Branch[];");
expect(result.value).toContain("export interface Box {");
expect(result.value).toContain("boxes: Box[];");
});

it("singularizes ambiguous -ses array names in interfaces", () => {
const result = toTypeScript('{"cases":[{"a":1}],"addresses":[{"b":2}],"statuses":[{"c":3}]}');
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("export interface Case {");
expect(result.value).toContain("cases: Case[];");
expect(result.value).toContain("export interface Address {");
expect(result.value).toContain("addresses: Address[];");
expect(result.value).toContain("export interface Status {");
expect(result.value).toContain("statuses: Status[];");
});

it("singularizes -ses and -zes array names in interfaces", () => {
const result = toTypeScript(
JSON.stringify({
classes: [{ id: 1 }],
viruses: [{ id: 2 }],
bonuses: [{ id: 3 }],
campuses: [{ id: 4 }],
buses: [{ id: 5 }],
causes: [{ id: 6 }],
houses: [{ id: 7 }],
responses: [{ id: 8 }],
courses: [{ id: 9 }],
releases: [{ id: 10 }],
licenses: [{ id: 11 }],
databases: [{ id: 12 }],
sizes: [{ id: 13 }],
prizes: [{ id: 14 }],
mazes: [{ id: 15 }],
quizzes: [{ id: 16 }],
}),
);
expect(result.ok).toBe(true);
if (!result.ok) return;

expect(result.value).toContain("export interface Class {");
expect(result.value).toContain("classes: Class[];");
expect(result.value).toContain("export interface Virus {");
expect(result.value).toContain("viruses: Virus[];");
expect(result.value).toContain("export interface Bonus {");
expect(result.value).toContain("bonuses: Bonus[];");
expect(result.value).toContain("export interface Campus {");
expect(result.value).toContain("campuses: Campus[];");
expect(result.value).toContain("export interface Bus {");
expect(result.value).toContain("buses: Bus[];");
expect(result.value).toContain("export interface Cause {");
expect(result.value).toContain("causes: Cause[];");
expect(result.value).toContain("export interface House {");
expect(result.value).toContain("houses: House[];");
expect(result.value).toContain("export interface Response {");
expect(result.value).toContain("responses: Response[];");
expect(result.value).toContain("export interface Course {");
expect(result.value).toContain("courses: Course[];");
expect(result.value).toContain("export interface Release {");
expect(result.value).toContain("releases: Release[];");
expect(result.value).toContain("export interface License {");
expect(result.value).toContain("licenses: License[];");
expect(result.value).toContain("export interface Database {");
expect(result.value).toContain("databases: Database[];");
expect(result.value).toContain("export interface Size {");
expect(result.value).toContain("sizes: Size[];");
expect(result.value).toContain("export interface Prize {");
expect(result.value).toContain("prizes: Prize[];");
expect(result.value).toContain("export interface Maze {");
expect(result.value).toContain("mazes: Maze[];");
expect(result.value).toContain("export interface Quiz {");
expect(result.value).toContain("quizzes: Quiz[];");
});
});

describe("singularize", () => {
it("singularizes all required maintainer corrections and preserves existing rules", () => {
// Sibilant plurals: -ches, -shes, -xes
expect(singularize("matches")).toBe("match");
expect(singularize("branches")).toBe("branch");
expect(singularize("hashes")).toBe("hash");
expect(singularize("boxes")).toBe("box");
expect(singularize("fixes")).toBe("fix");
expect(singularize("caches")).toBe("cach");

// -zes handling: -zzes vs non-doubled
expect(singularize("quizzes")).toBe("quiz");
expect(singularize("sizes")).toBe("size");
expect(singularize("prizes")).toBe("prize");
expect(singularize("mazes")).toBe("maze");

// Ambiguous -ses: stem ends in ss, tus, rus, nus, pus, bus -> remove es
expect(singularize("addresses")).toBe("address");
expect(singularize("classes")).toBe("class");
expect(singularize("statuses")).toBe("status");
expect(singularize("viruses")).toBe("virus");
expect(singularize("bonuses")).toBe("bonus");
expect(singularize("campuses")).toBe("campus");
expect(singularize("buses")).toBe("bus");

// Ambiguous -ses: stem does not end in those endings -> remove trailing s
expect(singularize("cases")).toBe("case");
expect(singularize("causes")).toBe("cause");
expect(singularize("houses")).toBe("house");
expect(singularize("responses")).toBe("response");
expect(singularize("courses")).toBe("course");
expect(singularize("releases")).toBe("release");
expect(singularize("licenses")).toBe("license");
expect(singularize("databases")).toBe("database");

// Preserved rules
expect(singularize("categories")).toBe("category");
expect(singularize("users")).toBe("user");
expect(singularize("boss")).toBe("bossItem");
expect(singularize("root")).toBe("rootItem");

// PascalCase support for TypeScript interface naming
expect(singularize("Addresses")).toBe("Address");
expect(singularize("Classes")).toBe("Class");
expect(singularize("Statuses")).toBe("Status");
expect(singularize("Viruses")).toBe("Virus");
expect(singularize("Bonuses")).toBe("Bonus");
expect(singularize("Campuses")).toBe("Campus");
expect(singularize("Buses")).toBe("Bus");
expect(singularize("Cases")).toBe("Case");
expect(singularize("Sizes")).toBe("Size");
expect(singularize("Quizzes")).toBe("Quiz");
});
});

/* -----------------------------------------------------------------------
Expand Down
18 changes: 14 additions & 4 deletions lib/json-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,20 @@ function pascalCase(s: string): string {
return /^[0-9]/.test(result) ? `_${result}` : result;
}

function singularize(name: string): string {
if (/ies$/.test(name)) return name.replace(/ies$/, "y");
if (/ses$/.test(name)) return name.replace(/es$/, "");
if (/s$/.test(name) && !/ss$/.test(name)) return name.replace(/s$/, "");
export function singularize(name: string): string {
if (/ies$/i.test(name)) return name.replace(/ies$/i, "y");
// Sibilant plurals with a doubled consonant: quizzes → quiz
if (/zzes$/i.test(name)) return name.replace(/zes$/i, "");
// Sibilant plurals: matches → match, branches → branch, hashes → hash, boxes → box, fixes → fix
if (/(ches|shes|xes)$/i.test(name)) return name.replace(/es$/i, "");
// Ambiguous -ses: remove "es" only when the stem ends in ss, tus, rus, nus, pus, or bus;
// otherwise remove only the trailing "s".
if (/ses$/i.test(name)) {
const stem = name.slice(0, -2);
if (/(ss|tus|rus|nus|pus|bus)$/i.test(stem)) return stem;
return name.slice(0, -1);
}
if (/s$/i.test(name) && !/ss$/i.test(name)) return name.replace(/s$/i, "");
return name + "Item";
}

Expand Down
Loading