From 0055534753762715a465c37b400bce3a53f3001f Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 6 Jul 2026 20:10:57 -0400 Subject: [PATCH] Make languageNamed accept display names and extensions again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v23.1.0 rewrite of languageNamed()/isLanguageName() (da1238c0) matched only the lowercase entries in each language's names array and threw "Unknown language name" on a miss. Before that, languageNamed() matched names, displayName, and extension, and returned undefined for unknown names. That broke consumers passing display names — most visibly app.quicktype.io, which stores the chosen language's display name (e.g. "TypeScript") in localStorage and crashed to a blank page for returning users. Restore the old behavior: match names, display names, and extensions case-insensitively (names take precedence over extensions so "js" still resolves to JavaScript, not Flow), and return undefined instead of throwing. An overload keeps the precise LanguageNameMap return type for calls with a known LanguageName literal while allowing arbitrary strings to return TargetLanguage | undefined. Add a regression check to the test harness, since fixtures only ever pass exact lowercase language names and cannot catch this. Fixes #2769 Co-Authored-By: Claude Fable 5 --- packages/quicktype-core/src/language/All.ts | 39 +++++--- test/check-language-named.ts | 103 ++++++++++++++++++++ test/test.ts | 6 ++ 3 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 test/check-language-named.ts diff --git a/packages/quicktype-core/src/language/All.ts b/packages/quicktype-core/src/language/All.ts index be1a29a4c..934908970 100644 --- a/packages/quicktype-core/src/language/All.ts +++ b/packages/quicktype-core/src/language/All.ts @@ -66,28 +66,35 @@ all satisfies readonly TargetLanguage[]; export function languageNamed( name: Name, + targetLanguages?: readonly TargetLanguage[], +): LanguageNameMap[Name]; +export function languageNamed( + name: string, + targetLanguages?: readonly TargetLanguage[], +): TargetLanguage | undefined; +export function languageNamed( + name: string, targetLanguages: readonly TargetLanguage[] = all, -): LanguageNameMap[Name] { - const foundLanguage = targetLanguages.find((language) => - language.names.includes(name), +): TargetLanguage | undefined { + // Names take precedence over extensions, so that e.g. "js" resolves + // to JavaScript, not to Flow (whose extension is "js"). + const lowerName = name.toLowerCase(); + const foundLanguage = targetLanguages.find( + (language) => + (language.names as readonly string[]).includes(lowerName) || + language.displayName.toLowerCase() === lowerName, ); - if (!foundLanguage) { - throw new Error(`Unknown language name: ${name}`); - } - return foundLanguage as LanguageNameMap[Name]; + return ( + foundLanguage ?? + targetLanguages.find( + (language) => language.extension.toLowerCase() === lowerName, + ) + ); } export function isLanguageName(maybeName: string): maybeName is LanguageName { - if ( - all.some((lang) => - (lang.names as readonly string[]).includes(maybeName), - ) - ) { - return true; - } - - return false; + return languageNamed(maybeName) !== undefined; } export function isLanguageDisplayName( diff --git a/test/check-language-named.ts b/test/check-language-named.ts new file mode 100644 index 000000000..42dfb20ed --- /dev/null +++ b/test/check-language-named.ts @@ -0,0 +1,103 @@ +// Guard: languageNamed() must accept display names and file extensions, and +// must return undefined for unknown names instead of throwing. +// +// Before quicktype 23.1.0, languageNamed() matched a language's names, +// displayName, and extension, and returned undefined when nothing matched. +// The 23.1.0 rewrite (da1238c0) made it match only the lowercase names +// arrays and *throw* on a miss. That broke consumers that pass display +// names — most visibly app.quicktype.io, which stores the chosen language's +// display name (e.g. "TypeScript") and crashed to a blank page for +// returning users: see https://github.com/glideapps/quicktype/issues/2769. +// +// The fixture harness never calls languageNamed() with anything but exact +// lowercase names, so it can't catch this; we assert on the API directly. + +import { isLanguageName, languageNamed } from "quicktype-core"; + +// input → expected displayName of the resolved language +const resolvedCases: Array<[string, string]> = [ + // exact lowercase names (the only thing 23.1.0 accepted) + ["typescript", "TypeScript"], + ["c++", "C++"], + // display names, as saved by app.quicktype.io + ["TypeScript", "TypeScript"], + ["C++", "C++"], + ["Objective-C", "Objective-C"], + ["JSON Schema", "JSON Schema"], + // case-insensitive + ["Typescript", "TypeScript"], + ["PYTHON", "Python"], + // extensions (matched only after names and display names, so + // Flow's extension "js" must not shadow JavaScript's name "js") + ["kt", "Kotlin"], + ["js", "JavaScript"], +]; + +const unknownNames = ["this-is-not-a-language", ""]; + +export function checkLanguageNamed(): void { + const failures: string[] = []; + + for (const [input, expected] of resolvedCases) { + let actual: string; + try { + actual = languageNamed(input)?.displayName ?? "undefined"; + } catch (e) { + actual = `throw: ${e}`; + } + + if (actual !== expected) { + failures.push( + `languageNamed(${JSON.stringify(input)}): expected ${expected}, got ${actual}`, + ); + } + + if (!isLanguageName(input)) { + failures.push( + `isLanguageName(${JSON.stringify(input)}): expected true, got false`, + ); + } + } + + for (const input of unknownNames) { + let actual: string | undefined; + try { + actual = languageNamed(input)?.displayName; + } catch (e) { + actual = `throw: ${e}`; + } + + if (actual !== undefined) { + failures.push( + `languageNamed(${JSON.stringify(input)}): expected undefined, got ${actual}`, + ); + } + + if (isLanguageName(input)) { + failures.push( + `isLanguageName(${JSON.stringify(input)}): expected false, got true`, + ); + } + } + + if (failures.length > 0) { + const failureList = failures.map((f) => ` ${f}`).join("\n"); + console.error( + `error: languageNamed()/isLanguageName() regressions: + +${failureList} + +languageNamed() must resolve names, display names, and extensions +(case-insensitively) and return undefined — not throw — for unknown names. +See https://github.com/glideapps/quicktype/issues/2769`, + ); + process.exit(1); + } +} + +// Allow running the check standalone: +// npx ts-node --project test/tsconfig.json test/check-language-named.ts +if (require.main === module) { + checkLanguageNamed(); + console.error("* languageNamed accepts names, display names, extensions"); +} diff --git a/test/test.ts b/test/test.ts index e3e5ddf76..4b891fb49 100755 --- a/test/test.ts +++ b/test/test.ts @@ -9,6 +9,7 @@ import { type Fixture, allFixtures } from "./fixtures"; import { affectedFixtures, divideParallelJobs } from "./buildkite"; import { checkCoreImportKeepsStdoutClean } from "./check-clean-import"; import { checkJavaEnumAcronymCasing } from "./check-java-acronym-names"; +import { checkLanguageNamed } from "./check-language-named"; import { checkCoreHasNoNodePrefixedImports } from "./check-no-node-imports"; import { checkURLInput } from "./check-url-input"; @@ -47,6 +48,11 @@ async function main(sources: string[]) { await checkURLInput(); } + // Regression check for issue #2769: languageNamed() must accept display + // names and extensions and return undefined for unknown names instead + // of throwing. The fixture harness only ever uses exact lowercase names. + checkLanguageNamed(); + let fixtures = affectedFixtures(); const fixturesFromCmdline = process.env.FIXTURE; if (fixturesFromCmdline) {