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 e9d9a82ed4..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; diff --git a/test/fixtures.ts b/test/fixtures.ts index 84616537e7..052925e6cd 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 @@ -1770,6 +1783,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 120f3170b0..2f0be883b8 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1149,6 +1149,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", 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..9277d5c869 --- /dev/null +++ b/test/unit/top-level-name-capitalization.test.ts @@ -0,0 +1,48 @@ +// 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"; + +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", () => { + // 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); + + expect(output).toContain(`export interface ${expected} {`); + }); +});