From 04e4d1a36cf8237f0889a74a5efe15c6ee8bcf29 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 17:50:28 -0400 Subject: [PATCH 1/4] fix(naming): respect casing of top-level names that collide with acronym dictionary (#2328) Co-Authored-By: gpt-5.6-sol via pi --- .../quicktype-core/src/support/Strings.ts | 7 ++- test/inputs/json/priority/name-style.json | 5 ++- .../top-level-name-capitalization.test.ts | 43 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 test/unit/top-level-name-capitalization.test.ts diff --git a/packages/quicktype-core/src/support/Strings.ts b/packages/quicktype-core/src/support/Strings.ts index e9d9a82ed4..2cb2b23b3e 100644 --- a/packages/quicktype-core/src/support/Strings.ts +++ b/packages/quicktype-core/src/support/Strings.ts @@ -488,8 +488,11 @@ export function splitIntoWords(s: string): WordInName[] { for (const [start, end, allUpper] of intervals) { const word = s.slice(start, end); const isAcronym = - (lastLowerCaseIndex !== undefined && allUpper) || - knownAcronyms.has(word.toLowerCase() as (typeof acronyms)[number]); + allUpper && + (lastLowerCaseIndex !== undefined || + knownAcronyms.has( + word.toLowerCase() as (typeof acronyms)[number], + )); words.push({ word, isAcronym }); } diff --git a/test/inputs/json/priority/name-style.json b/test/inputs/json/priority/name-style.json index 0241a1a9a7..97e0ae370e 100644 --- a/test/inputs/json/priority/name-style.json +++ b/test/inputs/json/priority/name-style.json @@ -5,5 +5,8 @@ "isMNISTLetter": true, "ZSR_HH_DEMO3_DELMATNR": true, "should_know_that_id_is_an_initialism": "yup", - " spaces are separators too ": true + " spaces are separators too ": true, + "Acme": { + "HTMLParser": true + } } diff --git a/test/unit/top-level-name-capitalization.test.ts b/test/unit/top-level-name-capitalization.test.ts new file mode 100644 index 0000000000..51239b9fb9 --- /dev/null +++ b/test/unit/top-level-name-capitalization.test.ts @@ -0,0 +1,43 @@ +// The fixture in test/inputs/json/priority/name-style.json exercises names +// that collide with the acronym dictionary, but round-trip fixtures cannot +// detect identifier casing because generated identifiers are self-consistent. +// Assert on the emitted identifier here as a complement to that fixture. + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], +}); + +async function renderTopLevel(topLevel: string): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: topLevel, schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": "true" }, + }); + return result.lines.join("\n"); +} + +describe("top-level acronym capitalization", () => { + test.each([ + ["Acme", "Acme"], + ["acme", "Acme"], + ["ACME", "ACME"], + ["HTMLParser", "HTMLParser"], + ])("renders %s as %s", async (topLevel, expected) => { + const output = await renderTopLevel(topLevel); + + expect(output).toContain(`export interface ${expected} {`); + }); +}); From 9ccb3d506a828fea4d2b99529538a39fba5451f1 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:10:49 -0400 Subject: [PATCH 2/4] fix(naming): only exempt mixed-case names from acronym dictionary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2328 fix made splitIntoWords treat a word as an acronym only when it was written in all-uppercase, which also stopped uniformly lowercase words that match the known-acronym dictionary (e.g. "json", "id", "asm") from being styled as acronyms. That regressed generated class names from "JSON" to "Json", "ID" to "Id", etc., breaking compilation for several languages — most visibly Kotlin/Klaxon, where a generated `data class Json` shadows Klaxon's imported `@Json` annotation in the keywords.json fixture. Narrow the exemption to mixed-case words only: a word carrying an explicit capitalization ("Foobar", "Acme") is never forced to an acronym, while a uniformly cased word ("foobar", "FOOBAR", "json") keeps the previous dictionary behavior. This keeps issue #2328 fixed (top-level "Acme" no longer becomes "ACME") and restores keywords.json output byte-for-byte to its pre-change form for java, kotlin, kotlin-jackson, csharp, haskell, elixir and scala3. Update the top-level-name unit test accordingly: lowercase "acme" has no capitalization to respect and stays "ACME", consistent with "json" → "JSON". Co-Authored-By: Claude --- packages/quicktype-core/src/support/Strings.ts | 11 +++++++++-- test/unit/top-level-name-capitalization.test.ts | 8 +++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/quicktype-core/src/support/Strings.ts b/packages/quicktype-core/src/support/Strings.ts index 2cb2b23b3e..41d6dc47dc 100644 --- a/packages/quicktype-core/src/support/Strings.ts +++ b/packages/quicktype-core/src/support/Strings.ts @@ -487,9 +487,16 @@ export function splitIntoWords(s: string): WordInName[] { const words: WordInName[] = []; for (const [start, end, allUpper] of intervals) { const word = s.slice(start, end); + // A word written in mixed case (e.g. "Foobar", "Acme") carries an + // explicit capitalization we must respect, so it is never treated as an + // acronym even when it collides with the known-acronym dictionary (see + // issue #2328). A uniformly cased word ("foobar", "FOOBAR") has no such + // signal, so a dictionary match still makes it an acronym. + const isMixedCase = + word !== word.toLowerCase() && word !== word.toUpperCase(); const isAcronym = - allUpper && - (lastLowerCaseIndex !== undefined || + (lastLowerCaseIndex !== undefined && allUpper) || + (!isMixedCase && knownAcronyms.has( word.toLowerCase() as (typeof acronyms)[number], )); diff --git a/test/unit/top-level-name-capitalization.test.ts b/test/unit/top-level-name-capitalization.test.ts index 51239b9fb9..f96921d11a 100644 --- a/test/unit/top-level-name-capitalization.test.ts +++ b/test/unit/top-level-name-capitalization.test.ts @@ -30,9 +30,15 @@ async function renderTopLevel(topLevel: string): Promise { } describe("top-level acronym capitalization", () => { + // A name written in mixed case carries an explicit capitalization that must + // be respected even when it collides with the known-acronym dictionary + // ("Acme" stays "Acme"). A uniformly cased name has no such signal, so a + // dictionary match still styles it as an acronym ("acme" -> "ACME"), which + // keeps names like a "json" property rendering as the acronym "JSON" and + // avoids regressing library-collision fixtures (e.g. Klaxon's @Json). test.each([ ["Acme", "Acme"], - ["acme", "Acme"], + ["acme", "ACME"], ["ACME", "ACME"], ["HTMLParser", "HTMLParser"], ])("renders %s as %s", async (topLevel, expected) => { From 78d3c689c55c5a377626c39ccd4ab9268e988ec8 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Wed, 22 Jul 2026 16:22:33 -0400 Subject: [PATCH 3/4] fix(naming): scope acronym casing fix to top-level names --- .../quicktype-core/src/ConvenienceRenderer.ts | 34 +++++++++++++++---- .../quicktype-core/src/support/Strings.ts | 31 +++++++++++------ test/inputs/json/priority/name-style.json | 5 +-- .../top-level-name-capitalization.test.ts | 19 +++++------ 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/packages/quicktype-core/src/ConvenienceRenderer.ts b/packages/quicktype-core/src/ConvenienceRenderer.ts index af8ff16031..e3b5dcd5ef 100644 --- a/packages/quicktype-core/src/ConvenienceRenderer.ts +++ b/packages/quicktype-core/src/ConvenienceRenderer.ts @@ -31,7 +31,7 @@ import { DependencyName, FixedName, type Name, - type Namer, + Namer, Namespace, SimpleName, keywordNamespace, @@ -51,7 +51,11 @@ import { type CommentOptions, isStringComment, } from "./support/Comments.js"; -import { trimEnd } from "./support/Strings.js"; +import { + splitIntoWords, + styleNameWithKnownAcronymIgnored, + trimEnd, +} from "./support/Strings.js"; import { assert, defined, nonNull, panic } from "./support/Support.js"; import { type Transformation, @@ -390,11 +394,27 @@ export abstract class ConvenienceRenderer extends Renderer { givenName: string, _maybeNamedType: Type | undefined, ): Name { - return new SimpleName( - [givenName], - defined(this._namedTypeNamer), - topLevelNameOrder, - ); + const namedTypeNamer = defined(this._namedTypeNamer); + const words = splitIntoWords(givenName); + const onlyWord = words.length === 1 ? words[0] : undefined; + const shouldPreserveCasing = + onlyWord?.isAcronym === true && + onlyWord.word !== onlyWord.word.toLowerCase() && + onlyWord.word !== onlyWord.word.toUpperCase(); + const topLevelNamer = shouldPreserveCasing + ? new Namer( + `${namedTypeNamer.name}-top-level`, + (rawName) => + styleNameWithKnownAcronymIgnored( + onlyWord.word, + namedTypeNamer.nameStyle, + rawName, + ), + namedTypeNamer.prefixes, + ) + : namedTypeNamer; + + return new SimpleName([givenName], topLevelNamer, topLevelNameOrder); } private addNameForTopLevel(type: Type, givenName: string): Name { diff --git a/packages/quicktype-core/src/support/Strings.ts b/packages/quicktype-core/src/support/Strings.ts index 41d6dc47dc..19887e2bd2 100644 --- a/packages/quicktype-core/src/support/Strings.ts +++ b/packages/quicktype-core/src/support/Strings.ts @@ -348,6 +348,25 @@ export function startWithLetter( const knownAcronyms = new Set(acronyms); +/** + * Applies a synchronous name style without recognizing one dictionary acronym. + * Name styles call `splitIntoWords` internally, so this preserves an explicitly + * cased name without changing acronym handling for other names. + */ +export function styleNameWithKnownAcronymIgnored( + acronym: string, + nameStyle: (rawName: string) => string, + rawName: string, +): string { + const normalized = acronym.toLowerCase() as (typeof acronyms)[number]; + const wasKnown = knownAcronyms.delete(normalized); + try { + return nameStyle(rawName); + } finally { + if (wasKnown) knownAcronyms.add(normalized); + } +} + export interface WordInName { isAcronym: boolean; word: string; @@ -487,19 +506,9 @@ export function splitIntoWords(s: string): WordInName[] { const words: WordInName[] = []; for (const [start, end, allUpper] of intervals) { const word = s.slice(start, end); - // A word written in mixed case (e.g. "Foobar", "Acme") carries an - // explicit capitalization we must respect, so it is never treated as an - // acronym even when it collides with the known-acronym dictionary (see - // issue #2328). A uniformly cased word ("foobar", "FOOBAR") has no such - // signal, so a dictionary match still makes it an acronym. - const isMixedCase = - word !== word.toLowerCase() && word !== word.toUpperCase(); const isAcronym = (lastLowerCaseIndex !== undefined && allUpper) || - (!isMixedCase && - knownAcronyms.has( - word.toLowerCase() as (typeof acronyms)[number], - )); + knownAcronyms.has(word.toLowerCase() as (typeof acronyms)[number]); words.push({ word, isAcronym }); } diff --git a/test/inputs/json/priority/name-style.json b/test/inputs/json/priority/name-style.json index 97e0ae370e..0241a1a9a7 100644 --- a/test/inputs/json/priority/name-style.json +++ b/test/inputs/json/priority/name-style.json @@ -5,8 +5,5 @@ "isMNISTLetter": true, "ZSR_HH_DEMO3_DELMATNR": true, "should_know_that_id_is_an_initialism": "yup", - " spaces are separators too ": true, - "Acme": { - "HTMLParser": true - } + " spaces are separators too ": true } diff --git a/test/unit/top-level-name-capitalization.test.ts b/test/unit/top-level-name-capitalization.test.ts index f96921d11a..9277d5c869 100644 --- a/test/unit/top-level-name-capitalization.test.ts +++ b/test/unit/top-level-name-capitalization.test.ts @@ -1,7 +1,7 @@ -// The fixture in test/inputs/json/priority/name-style.json exercises names -// that collide with the acronym dictionary, but round-trip fixtures cannot -// detect identifier casing because generated identifiers are self-consistent. -// Assert on the emitted identifier here as a complement to that fixture. +// The fixture harness always supplies each language's configured top-level +// name, and round trips cannot detect identifier casing because generated +// identifiers are self-consistent. Generate directly to assert the requested +// top-level name. import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; import { describe, expect, test } from "vitest"; @@ -30,17 +30,16 @@ async function renderTopLevel(topLevel: string): Promise { } describe("top-level acronym capitalization", () => { - // A name written in mixed case carries an explicit capitalization that must - // be respected even when it collides with the known-acronym dictionary - // ("Acme" stays "Acme"). A uniformly cased name has no such signal, so a - // dictionary match still styles it as an acronym ("acme" -> "ACME"), which - // keeps names like a "json" property rendering as the acronym "JSON" and - // avoids regressing library-collision fixtures (e.g. Klaxon's @Json). + // A single mixed-case name carries explicit capitalization that must be + // respected even when it collides with the known-acronym dictionary + // ("Acme" stays "Acme"). Uniformly cased names and acronym words within + // compound names keep the existing acronym-style behavior. test.each([ ["Acme", "Acme"], ["acme", "ACME"], ["ACME", "ACME"], ["HTMLParser", "HTMLParser"], + ["FaqCoordinate", "FAQCoordinate"], ])("renders %s as %s", async (topLevel, expected) => { const output = await renderTopLevel(topLevel); From 7d82951c3d7333d50562e4b704e160c4c8731d9f Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Wed, 22 Jul 2026 18:02:05 -0400 Subject: [PATCH 4/4] test(naming): add top-level acronym fixture coverage --- test/fixtures.ts | 14 ++++++++++++++ test/fixtures/typescript-top-level-name/main.ts | 13 +++++++++++++ .../typescript-top-level-name/tsconfig.json | 9 +++++++++ test/languages.ts | 9 +++++++++ 4 files changed, 45 insertions(+) create mode 100644 test/fixtures/typescript-top-level-name/main.ts create mode 100644 test/fixtures/typescript-top-level-name/tsconfig.json diff --git a/test/fixtures.ts b/test/fixtures.ts index 788bebd537..59f579205a 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -558,6 +558,19 @@ class JSONFixture extends LanguageFixture { } } +class TypeScriptTopLevelNameFixture extends JSONFixture { + constructor() { + super( + languages.TypeScriptTopLevelNameLanguage, + "typescript-top-level-name", + ); + } + + runForName(name: string): boolean { + return name === "typescript" || super.runForName(name); + } +} + // This fixture tests generating code for language X from JSON, // then generating code for Y from the code for X, making sure // that the resulting code for Y accepts the JSON by running it @@ -1767,6 +1780,7 @@ export const allFixtures: Fixture[] = [ ), new JSONFixture(languages.ObjectiveCLanguage), new JSONFixture(languages.TypeScriptLanguage), + new TypeScriptTopLevelNameFixture(), new JSONFixture(languages.TypeScriptZodLanguage), new JSONFixture(languages.TypeScriptEffectSchemaLanguage), new JSONFixture(languages.FlowLanguage), diff --git a/test/fixtures/typescript-top-level-name/main.ts b/test/fixtures/typescript-top-level-name/main.ts new file mode 100644 index 0000000000..419df57929 --- /dev/null +++ b/test/fixtures/typescript-top-level-name/main.ts @@ -0,0 +1,13 @@ +import * as Acme from "./TopLevel"; + +declare function require(path: string): any; +const fs = require("fs"); +const process = require("process"); + +const sample = process.argv[2]; +const json = fs.readFileSync(sample); + +const value = Acme.Convert.toAcme(json); +const backToJson = Acme.Convert.acmeToJson(value); + +console.log(backToJson); diff --git a/test/fixtures/typescript-top-level-name/tsconfig.json b/test/fixtures/typescript-top-level-name/tsconfig.json new file mode 100644 index 0000000000..6bcd3937d1 --- /dev/null +++ b/test/fixtures/typescript-top-level-name/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es6", + "noEmit": true, + "noImplicitAny": true, + "strictNullChecks": false + }, + "files": ["main.ts"] +} diff --git a/test/languages.ts b/test/languages.ts index d413a66bcd..f84e9c5b13 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1120,6 +1120,15 @@ export const TypeScriptLanguage: Language = { sourceFiles: ["src/language/TypeScript/index.ts"], }; +export const TypeScriptTopLevelNameLanguage: Language = { + ...TypeScriptLanguage, + base: "test/fixtures/typescript-top-level-name", + topLevel: "Acme", + includeJSON: ["name-style.json"], + skipMiscJSON: true, + quickTestRendererOptions: [], +}; + export const JavaScriptLanguage: Language = { name: "javascript", base: "test/fixtures/javascript",