diff --git a/lib/__tests__/json-utils.test.ts b/lib/__tests__/json-utils.test.ts
index 73e0c1e..1f889c4 100644
--- a/lib/__tests__/json-utils.test.ts
+++ b/lib/__tests__/json-utils.test.ts
@@ -12,6 +12,7 @@ import {
parseJson5Compat,
diffLines,
validate,
+ singularize,
SAMPLE_JSON,
} from "../json-utils";
@@ -292,6 +293,90 @@ describe("toXML", () => {
expect(result.value).toContain(" x");
});
+ 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("a");
+ expect(result.value).toContain("b");
+ expect(result.value).toContain("c");
+ expect(result.value).toContain("d");
+ expect(result.value).toContain("e");
+ expect(result.value).toContain("f");
+ });
+
+ 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("x");
+ expect(result.value).toContain("
y");
+ expect(result.value).toContain("z");
+ });
+
+ 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("a");
+ expect(stems.value).toContain("b");
+ expect(stems.value).toContain("c");
+ expect(stems.value).toContain("d");
+ expect(stems.value).toContain("e");
+ expect(stems.value).toContain("f");
+ expect(stems.value).toContain("g");
+ }
+
+ 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("a");
+ expect(nonStems.value).toContain("b");
+ expect(nonStems.value).toContain("c");
+ expect(nonStems.value).toContain("d");
+ expect(nonStems.value).toContain("e");
+ expect(nonStems.value).toContain("f");
+ expect(nonStems.value).toContain("g");
+ expect(nonStems.value).toContain("h");
+ }
+ });
+
+ 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("s");
+ expect(result.value).toContain("p");
+ expect(result.value).toContain("m");
+ expect(result.value).toContain("q");
+ expect(result.value).toContain("c");
+ });
+
+ 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("a");
+ expect(result.value).toContain("b");
+ expect(result.value).toContain("c");
+ expect(result.value).toContain("d");
+ expect(result.value).toContain("e");
+ });
+
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);
@@ -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");
+ });
});
/* -----------------------------------------------------------------------
diff --git a/lib/json-utils.ts b/lib/json-utils.ts
index b34bafc..83ffa3f 100644
--- a/lib/json-utils.ts
+++ b/lib/json-utils.ts
@@ -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";
}