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/unit/language-named.test.ts b/test/unit/language-named.test.ts new file mode 100644 index 000000000..749adb40e --- /dev/null +++ b/test/unit/language-named.test.ts @@ -0,0 +1,55 @@ +// 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"; +import { describe, expect, test } from "vitest"; + +// 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", ""]; + +describe("languageNamed", () => { + test.each(resolvedCases)("resolves %j to %j", (input, expected) => { + expect(languageNamed(input)?.displayName).toBe(expected); + }); + + test.each(resolvedCases)("isLanguageName(%j) is true", (input) => { + expect(isLanguageName(input)).toBe(true); + }); + + test.each(unknownNames)( + "returns undefined for unknown name %j", + (input) => { + expect(languageNamed(input)).toBeUndefined(); + expect(isLanguageName(input)).toBe(false); + }, + ); +});