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"; }