Skip to content
Open
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
34 changes: 27 additions & 7 deletions packages/quicktype-core/src/ConvenienceRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
DependencyName,
FixedName,
type Name,
type Namer,
Namer,
Namespace,
SimpleName,
keywordNamespace,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions packages/quicktype-core/src/support/Strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions test/fixtures/typescript-top-level-name/main.ts
Original file line number Diff line number Diff line change
@@ -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);
9 changes: 9 additions & 0 deletions test/fixtures/typescript-top-level-name/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "es6",
"noEmit": true,
"noImplicitAny": true,
"strictNullChecks": false
},
"files": ["main.ts"]
}
9 changes: 9 additions & 0 deletions test/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions test/unit/top-level-name-capitalization.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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} {`);
});
});
Loading