From 7341f4aad4fc4d839be15ba6b2e11428208c585e Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 16 Sep 2026 22:04:15 +0000 Subject: [PATCH 01/19] feat(gleam): add renderer skeleton Create packages/quicktype-core/src/language/Gleam/ with index, language, GleamRenderer, constants, and utils modules mirroring the Crystal sibling. Register GleamTargetLanguage in All.ts and re-export from language/index.ts. `node dist/index.js --lang gleam` now emits a Gleam module for trivial JSON input, and `-o Foo.gleam` extension inference resolves to the language. --- packages/quicktype-core/src/language/All.ts | 2 ++ .../src/language/Gleam/GleamRenderer.ts | 36 +++++++++++++++++++ .../src/language/Gleam/constants.ts | 2 ++ .../src/language/Gleam/index.ts | 2 ++ .../src/language/Gleam/language.ts | 36 +++++++++++++++++++ .../src/language/Gleam/utils.ts | 15 ++++++++ packages/quicktype-core/src/language/index.ts | 1 + 7 files changed, 94 insertions(+) create mode 100644 packages/quicktype-core/src/language/Gleam/GleamRenderer.ts create mode 100644 packages/quicktype-core/src/language/Gleam/constants.ts create mode 100644 packages/quicktype-core/src/language/Gleam/index.ts create mode 100644 packages/quicktype-core/src/language/Gleam/language.ts create mode 100644 packages/quicktype-core/src/language/Gleam/utils.ts diff --git a/packages/quicktype-core/src/language/All.ts b/packages/quicktype-core/src/language/All.ts index 94359ae74a..a12694797b 100644 --- a/packages/quicktype-core/src/language/All.ts +++ b/packages/quicktype-core/src/language/All.ts @@ -7,6 +7,7 @@ import { CrystalTargetLanguage } from "./Crystal/index.js"; import { DartTargetLanguage } from "./Dart/index.js"; import { ElixirTargetLanguage } from "./Elixir/index.js"; import { ElmTargetLanguage } from "./Elm/index.js"; +import { GleamTargetLanguage } from "./Gleam/index.js"; import { GoTargetLanguage } from "./Golang/index.js"; import { HaskellTargetLanguage } from "./Haskell/index.js"; import { JSONSchemaTargetLanguage } from "./JSONSchema/index.js"; @@ -44,6 +45,7 @@ export const all = [ new ElixirTargetLanguage(), new ElmTargetLanguage(), new FlowTargetLanguage(), + new GleamTargetLanguage(), new GoTargetLanguage(), new HaskellTargetLanguage(), new JavaTargetLanguage(), diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts new file mode 100644 index 0000000000..0620bd4774 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -0,0 +1,36 @@ +import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; +import { type Namer, funPrefixNamer } from "../../Naming.js"; + +// A placeholder name style until Step 2 wires up the real Gleam namers. +const placeholderNamer = funPrefixNamer("placeholder", (original: string) => + original.replace(/[^A-Za-z0-9_]/g, "_"), +); + +export class GleamRenderer extends ConvenienceRenderer { + protected makeNamedTypeNamer(): Namer { + return placeholderNamer; + } + + protected namerForObjectProperty(): Namer | null { + return placeholderNamer; + } + + protected makeUnionMemberNamer(): Namer | null { + return placeholderNamer; + } + + protected makeEnumCaseNamer(): Namer | null { + return placeholderNamer; + } + + protected get commentLineStart(): string { + return "// "; + } + + protected emitSourceStructure(): void { + this.emitLine("// Generated by quicktype"); + this.ensureBlankLine(); + this.emitLine("import gleam/dynamic/decode"); + this.emitLine("import gleam/json"); + } +} diff --git a/packages/quicktype-core/src/language/Gleam/constants.ts b/packages/quicktype-core/src/language/Gleam/constants.ts new file mode 100644 index 0000000000..917e2c6b26 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/constants.ts @@ -0,0 +1,2 @@ +// Gleam's reserved words. Populated in Step 2. +export const keywords: readonly string[] = []; diff --git a/packages/quicktype-core/src/language/Gleam/index.ts b/packages/quicktype-core/src/language/Gleam/index.ts new file mode 100644 index 0000000000..c71730d750 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/index.ts @@ -0,0 +1,2 @@ +export { GleamTargetLanguage } from "./language.js"; +export { GleamRenderer } from "./GleamRenderer.js"; diff --git a/packages/quicktype-core/src/language/Gleam/language.ts b/packages/quicktype-core/src/language/Gleam/language.ts new file mode 100644 index 0000000000..8457cc8ff2 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/language.ts @@ -0,0 +1,36 @@ +import type { RenderContext } from "../../Renderer.js"; +import type { IntegerRange } from "../../support/IntegerRange.js"; +import { TargetLanguage } from "../../TargetLanguage.js"; + +import { GleamRenderer } from "./GleamRenderer.js"; + +export const gleamLanguageConfig = { + displayName: "Gleam", + names: ["gleam"], + extension: "gleam", +} as const; + +export class GleamTargetLanguage extends TargetLanguage< + typeof gleamLanguageConfig +> { + public constructor() { + super(gleamLanguageConfig); + } + + // Gleam's Int is an Erlang arbitrary-precision integer. + public getSupportedIntegerRange(): IntegerRange | null { + return null; + } + + protected makeRenderer(renderContext: RenderContext): GleamRenderer { + return new GleamRenderer(this, renderContext); + } + + protected get defaultIndentation(): string { + return " "; + } + + public getOptions(): Record { + return {}; + } +} diff --git a/packages/quicktype-core/src/language/Gleam/utils.ts b/packages/quicktype-core/src/language/Gleam/utils.ts new file mode 100644 index 0000000000..6fedca20b1 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/utils.ts @@ -0,0 +1,15 @@ +import { + escapeNonPrintableMapper, + intToHex, + isPrintable, + utf32ConcatMap, +} from "../../support/Strings.js"; + +function standardUnicodeGleamEscape(codePoint: number): string { + // Gleam string escapes use the `\u{...}` form. + return `\\u{${intToHex(codePoint, 4)}}`; +} + +export const gleamStringEscape = utf32ConcatMap( + escapeNonPrintableMapper(isPrintable, standardUnicodeGleamEscape), +); diff --git a/packages/quicktype-core/src/language/index.ts b/packages/quicktype-core/src/language/index.ts index f45064466e..9bf492bd0a 100644 --- a/packages/quicktype-core/src/language/index.ts +++ b/packages/quicktype-core/src/language/index.ts @@ -5,6 +5,7 @@ export * from "./CSharp/index.js"; export * from "./Dart/index.js"; export * from "./Elixir/index.js"; export * from "./Elm/index.js"; +export * from "./Gleam/index.js"; export * from "./Golang/index.js"; export * from "./Haskell/index.js"; export * from "./Java/index.js"; From ffe9b1992f3eeb6aaecab5285a5250e64f6f0262 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 16 Sep 2026 22:06:08 +0000 Subject: [PATCH 02/19] feat(gleam): reserve keywords and normalize names Populate constants.ts with Gleam's reserved words (keywords used today plus identifiers reserved for future use) and the imported module aliases. Add snake_case and PascalCase naming functions in utils.ts and wire the four namers plus forbiddenNamesForGlobalNamespace into GleamRenderer. --- .../src/language/Gleam/GleamRenderer.ts | 47 +++++++++++---- .../src/language/Gleam/constants.ts | 42 +++++++++++++- .../src/language/Gleam/utils.ts | 58 +++++++++++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index 0620bd4774..122748f3ea 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -1,26 +1,53 @@ -import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; -import { type Namer, funPrefixNamer } from "../../Naming.js"; +import { + ConvenienceRenderer, + type ForbiddenWordsInfo, +} from "../../ConvenienceRenderer.js"; +import type { Name, Namer } from "../../Naming.js"; +import type { ClassType, EnumType, UnionType } from "../../Type/index.js"; -// A placeholder name style until Step 2 wires up the real Gleam namers. -const placeholderNamer = funPrefixNamer("placeholder", (original: string) => - original.replace(/[^A-Za-z0-9_]/g, "_"), -); +import { forbiddenModuleNames, keywords } from "./constants.js"; +import { pascalNamingFunction, snakeNamingFunction } from "./utils.js"; export class GleamRenderer extends ConvenienceRenderer { protected makeNamedTypeNamer(): Namer { - return placeholderNamer; + return pascalNamingFunction; } protected namerForObjectProperty(): Namer | null { - return placeholderNamer; + return snakeNamingFunction; } protected makeUnionMemberNamer(): Namer | null { - return placeholderNamer; + return pascalNamingFunction; } protected makeEnumCaseNamer(): Namer | null { - return placeholderNamer; + return pascalNamingFunction; + } + + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return [...keywords, ...forbiddenModuleNames]; + } + + protected forbiddenForObjectProperties( + _c: ClassType, + _className: Name, + ): ForbiddenWordsInfo { + return { names: [], includeGlobalForbidden: true }; + } + + protected forbiddenForUnionMembers( + _u: UnionType, + _unionName: Name, + ): ForbiddenWordsInfo { + return { names: [], includeGlobalForbidden: true }; + } + + protected forbiddenForEnumCases( + _e: EnumType, + _enumName: Name, + ): ForbiddenWordsInfo { + return { names: [], includeGlobalForbidden: true }; } protected get commentLineStart(): string { diff --git a/packages/quicktype-core/src/language/Gleam/constants.ts b/packages/quicktype-core/src/language/Gleam/constants.ts index 917e2c6b26..66615e31c2 100644 --- a/packages/quicktype-core/src/language/Gleam/constants.ts +++ b/packages/quicktype-core/src/language/Gleam/constants.ts @@ -1,2 +1,40 @@ -// Gleam's reserved words. Populated in Step 2. -export const keywords: readonly string[] = []; +// Gleam's reserved words. This covers both the keywords the compiler uses +// today and the identifiers it reserves for future use — Gleam rejects both +// when they appear as a value, field, or type name. +// +// Verified against the Gleam compiler's parser (`compiler-core`, Gleam 1.x). +export const keywords = [ + "as", + "assert", + "auto", + "case", + "const", + "delegate", + "derive", + "echo", + "else", + "fn", + "if", + "implement", + "import", + "let", + "macro", + "opaque", + "panic", + "pub", + "test", + "todo", + "type", + "use", +] as const; + +// Module aliases the generated code imports at the top level. Generated +// function names must not collide with these, or references inside the module +// would resolve to the import instead. +export const forbiddenModuleNames = [ + "decode", + "dict", + "int", + "json", + "option", +] as const; diff --git a/packages/quicktype-core/src/language/Gleam/utils.ts b/packages/quicktype-core/src/language/Gleam/utils.ts index 6fedca20b1..be7e10f9fe 100644 --- a/packages/quicktype-core/src/language/Gleam/utils.ts +++ b/packages/quicktype-core/src/language/Gleam/utils.ts @@ -1,10 +1,68 @@ +import { funPrefixNamer } from "../../Naming.js"; import { + allLowerWordStyle, + combineWords, escapeNonPrintableMapper, + firstUpperWordStyle, intToHex, + isAscii, + isLetterOrUnderscore, + isLetterOrUnderscoreOrDigit, isPrintable, + legalizeCharacters, + splitIntoWords, utf32ConcatMap, } from "../../support/Strings.js"; +function isAsciiLetterOrUnderscoreOrDigit(codePoint: number): boolean { + if (!isAscii(codePoint)) { + return false; + } + + return isLetterOrUnderscoreOrDigit(codePoint); +} + +function isAsciiLetterOrUnderscore(codePoint: number): boolean { + if (!isAscii(codePoint)) { + return false; + } + + return isLetterOrUnderscore(codePoint); +} + +const legalizeName = legalizeCharacters(isAsciiLetterOrUnderscoreOrDigit); + +function gleamStyle(original: string, isSnakeCase: boolean): string { + const words = splitIntoWords(original); + + const wordStyle = isSnakeCase ? allLowerWordStyle : firstUpperWordStyle; + + const combined = combineWords( + words, + legalizeName, + wordStyle, + wordStyle, + wordStyle, + wordStyle, + isSnakeCase ? "_" : "", + isAsciiLetterOrUnderscore, + ); + + return combined === "_" ? "_underscore" : combined; +} + +// snake_case, for functions, record fields, and local values. +export const snakeNamingFunction = funPrefixNamer( + "default", + (original: string) => gleamStyle(original, true), +); + +// PascalCase, for type names, record/union constructors, and enum cases. +export const pascalNamingFunction = funPrefixNamer( + "pascal", + (original: string) => gleamStyle(original, false), +); + function standardUnicodeGleamEscape(codePoint: number): string { // Gleam string escapes use the `\u{...}` form. return `\\u{${intToHex(codePoint, 4)}}`; From 49821fd1781799dbb70f8144c18a5d32a6615031 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 16 Sep 2026 22:47:39 +0000 Subject: [PATCH 03/19] feat(gleam): emit types and encoders Emit record types, enum types, tagged unions, the recursive JsonValue type for `any`, and one `_to_json(x) -> json.Json` encoder per type. Optional and nullable properties collapse to `option.Option`, encoded by omitting the key on `None`. Declare `supportsOptionalClassProperties` and `supportsUnionsWithBothNumberTypes` on the target language. To satisfy `gleam format --check`, add a small Wadler-style pretty-printer (pretty.ts) that reproduces the formatter's exact 80-column layout, including list-spread breaking, the `json.object([...])` hug, and the trailing-lambda break with its one-column slack. Reserve Gleam prelude type and constructor names and the fixed JsonValue identifiers, and force identifiers to begin with a letter. Verified: 53/54 priority+sample JSON inputs and all 88 schema inputs build with `gleam build` and pass `gleam format --check`. The lone exception, blns-object, hits a gleam Erlang-backend bug on an adversarial key and is a step 6 skip candidate. --- .../src/language/Gleam/GleamRenderer.ts | 717 +++++++++++++++++- .../src/language/Gleam/constants.ts | 38 + .../src/language/Gleam/language.ts | 11 + .../src/language/Gleam/pretty.ts | 256 +++++++ .../src/language/Gleam/utils.ts | 15 +- 5 files changed, 1022 insertions(+), 15 deletions(-) create mode 100644 packages/quicktype-core/src/language/Gleam/pretty.ts diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index 122748f3ea..bc7cc5c010 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -2,13 +2,70 @@ import { ConvenienceRenderer, type ForbiddenWordsInfo, } from "../../ConvenienceRenderer.js"; -import type { Name, Namer } from "../../Naming.js"; -import type { ClassType, EnumType, UnionType } from "../../Type/index.js"; +import { DependencyName, type Name, type Namer } from "../../Naming.js"; +import type { RenderContext } from "../../Renderer.js"; +import type { TargetLanguage } from "../../TargetLanguage.js"; +import { + matchType, + nullableFromUnion, + removeNullFromUnion, +} from "../../Type/TypeUtils.js"; +import { + ClassType, + type EnumType, + type Type, + UnionType, +} from "../../Type/index.js"; -import { forbiddenModuleNames, keywords } from "./constants.js"; -import { pascalNamingFunction, snakeNamingFunction } from "./utils.js"; +import { + forbiddenModuleNames, + jsonValueNames, + keywords, + preludeNames, +} from "./constants.js"; +import { + type Doc, + MAX_WIDTH, + call, + flat, + hugCall, + isCollection, + lambda, + list, + render, + seq, + text, + tuple, +} from "./pretty.js"; +import { + gleamStringEscape, + pascalNamingFunction, + snakeNamingFunction, +} from "./utils.js"; + +interface NamedTypeFunctions { + decoder: DependencyName; + encoder: DependencyName; +} export class GleamRenderer extends ConvenienceRenderer { + private readonly _namedTypeFunctions = new Map(); + + private readonly _topLevelFunctions = new Map(); + + private _needsJsonValue = false; + + private _needsOption = false; + + private _needsDict = false; + + public constructor( + targetLanguage: TargetLanguage, + renderContext: RenderContext, + ) { + super(targetLanguage, renderContext); + } + protected makeNamedTypeNamer(): Namer { return pascalNamingFunction; } @@ -25,8 +82,23 @@ export class GleamRenderer extends ConvenienceRenderer { return pascalNamingFunction; } + // Gleam constructors and enum cases all share one module-level value + // namespace, so they must be globally unique. + protected get unionMembersInGlobalNamespace(): boolean { + return true; + } + + protected get enumCasesInGlobalNamespace(): boolean { + return true; + } + protected forbiddenNamesForGlobalNamespace(): readonly string[] { - return [...keywords, ...forbiddenModuleNames]; + return [ + ...keywords, + ...forbiddenModuleNames, + ...jsonValueNames, + ...preludeNames, + ]; } protected forbiddenForObjectProperties( @@ -50,14 +122,641 @@ export class GleamRenderer extends ConvenienceRenderer { return { names: [], includeGlobalForbidden: true }; } + // Prefix union constructors with the union name so they read well and stay + // globally unique (e.g. `FooString`, `FooInteger`). + protected proposeUnionMemberName( + u: UnionType, + unionName: Name, + fieldType: Type, + lookup: (n: Name) => string, + ): string { + const fieldName = super.proposeUnionMemberName( + u, + unionName, + fieldType, + lookup, + ); + return `${lookup(unionName)}_${fieldName}`; + } + + protected makeNamedTypeDependencyNames( + _t: Type, + typeName: Name, + ): DependencyName[] { + const functions = this.makeConversionNames(typeName); + this._namedTypeFunctions.set(typeName, functions); + return [functions.encoder, functions.decoder]; + } + + protected makeTopLevelDependencyNames( + t: Type, + topLevelName: Name, + ): DependencyName[] { + // Named-type top-levels already have conversion functions from their + // named-type dependency names; only aliases need their own. + if (this.namedTypeToNameForTopLevel(t) !== undefined) { + return []; + } + + const functions = this.makeConversionNames(topLevelName); + this._topLevelFunctions.set(topLevelName, functions); + return [functions.encoder, functions.decoder]; + } + + private makeConversionNames(typeName: Name): NamedTypeFunctions { + const encoder = new DependencyName( + snakeNamingFunction, + typeName.order, + (lookup) => `${lookup(typeName)}_to_json`, + ); + const decoder = new DependencyName( + snakeNamingFunction, + typeName.order, + (lookup) => `${lookup(typeName)}_decoder`, + ); + return { encoder, decoder }; + } + protected get commentLineStart(): string { return "// "; } + private nameToString(name: Name): string { + return this.sourcelikeToString(name); + } + + private encoderName(t: Type): string { + const name = this.nameForNamedType(t); + const functions = this._namedTypeFunctions.get(name); + if (functions === undefined) { + throw new Error(`No conversion functions for type ${name}`); + } + + return this.nameToString(functions.encoder); + } + + // Manual, absolute-column line emission. The renderer keeps its own + // indentation at zero and bakes indentation into the emitted text so that + // the pretty-printer's byte-exact layout survives to the output. + private line(indent: number, content = ""): void { + if (content === "") { + this.emitLine(); + return; + } + + this.emitLine(" ".repeat(indent), content); + } + + private emitDoc(indent: number, doc: Doc): void { + const rendered = render(doc, indent, indent); + const full = `${" ".repeat(indent)}${rendered.text}`; + for (const physicalLine of full.split("\n")) { + this.emitLine(physicalLine); + } + } + + // Emit `lhs rhs`, reproducing how `gleam format` lays out `let` and + // case-arm right-hand sides: keep a collection literal attached to the + // operator, but push any other over-long expression to the next line. + private emitBinding( + indent: number, + lhs: string, + op: string, + rhs: Doc, + ): void { + // A binding moves an over-long right-hand side to the next line (or + // hugs a collection literal); breaking there does not free a trailing + // lambda's `}`, so no trailing slack applies to the fit decision. + const prefix = `${lhs} ${op} `; + const flatLine = `${prefix}${flat(rhs)}`; + if (indent + flatLine.length <= MAX_WIDTH) { + this.line(indent, flatLine); + return; + } + + if (isCollection(rhs)) { + const rendered = render(rhs, indent, indent + prefix.length); + const full = `${" ".repeat(indent)}${prefix}${rendered.text}`; + for (const physicalLine of full.split("\n")) { + this.emitLine(physicalLine); + } + + return; + } + + this.line(indent, `${lhs} ${op}`); + this.emitDoc(indent + 2, rhs); + } + + private typeDoc(t: Type): Doc { + return matchType( + t, + (_anyType) => text("JsonValue"), + (_nullType) => text("Nil"), + (_boolType) => text("Bool"), + (_integerType) => text("Int"), + (_doubleType) => text("Float"), + (_stringType) => text("String"), + (arrayType) => call("List", [this.typeDoc(arrayType.items)]), + (classType) => + text(this.nameToString(this.nameForNamedType(classType))), + (mapType) => + call("dict.Dict", [ + text("String"), + this.typeDoc(mapType.values), + ]), + (enumType) => + text(this.nameToString(this.nameForNamedType(enumType))), + (unionType) => { + const nullable = nullableFromUnion(unionType); + if (nullable !== null) { + return call("option.Option", [this.typeDoc(nullable)]); + } + + const [hasNull] = removeNullFromUnion(unionType); + const name = text( + this.nameToString(this.nameForNamedType(unionType)), + ); + return hasNull !== null ? call("option.Option", [name]) : name; + }, + ); + } + + private encodeDoc(t: Type, value: Doc): Doc { + return matchType( + t, + (_anyType) => call("json_value_to_json", [value]), + (_nullType) => text("json.null()"), + (_boolType) => call("json.bool", [value]), + (_integerType) => call("json.int", [value]), + (_doubleType) => call("json.float", [value]), + (_stringType) => call("json.string", [value]), + (arrayType) => + call("json.array", [value, this.encoderFn(arrayType.items)]), + (classType) => call(this.encoderName(classType), [value]), + (mapType) => + call("json.dict", [ + value, + lambda("key", text("key")), + this.encoderFn(mapType.values), + ]), + (enumType) => call(this.encoderName(enumType), [value]), + (unionType) => { + const nullable = nullableFromUnion(unionType); + if (nullable !== null) { + return call("json.nullable", [ + value, + this.encoderFn(nullable), + ]); + } + + const [hasNull] = removeNullFromUnion(unionType); + if (hasNull !== null) { + return call("json.nullable", [ + value, + text(this.encoderName(unionType)), + ]); + } + + return call(this.encoderName(unionType), [value]); + }, + ); + } + + // A `fn(a) -> json.Json` value that encodes a `t`. + private encoderFn(t: Type): Doc { + return matchType( + t, + (_anyType) => text("json_value_to_json"), + (_nullType) => lambda("_", text("json.null()")), + (_boolType) => text("json.bool"), + (_integerType) => text("json.int"), + (_doubleType) => text("json.float"), + (_stringType) => text("json.string"), + (arrayType) => + lambda( + "value", + call("json.array", [ + text("value"), + this.encoderFn(arrayType.items), + ]), + ), + (classType) => text(this.encoderName(classType)), + (mapType) => + lambda( + "value", + call("json.dict", [ + text("value"), + lambda("key", text("key")), + this.encoderFn(mapType.values), + ]), + ), + (enumType) => text(this.encoderName(enumType)), + (unionType) => { + const nullable = nullableFromUnion(unionType); + if (nullable !== null) { + return lambda( + "value", + call("json.nullable", [ + text("value"), + this.encoderFn(nullable), + ]), + ); + } + + const [hasNull] = removeNullFromUnion(unionType); + if (hasNull !== null) { + return lambda( + "value", + call("json.nullable", [ + text("value"), + text(this.encoderName(unionType)), + ]), + ); + } + + return text(this.encoderName(unionType)); + }, + ); + } + + // Whether a class property is represented as `option.Option(_)` in Gleam. + // Both absent (optional) and null (nullable) properties collapse to + // `Option`. + private isGleamOptional(t: Type, isOptional: boolean): boolean { + if (t instanceof UnionType) { + if (nullableFromUnion(t) !== null) { + return true; + } + + const [hasNull] = removeNullFromUnion(t); + if (hasNull !== null) { + return true; + } + } + + return isOptional; + } + + // The non-null Gleam type wrapped inside a property's `Option(_)`. + private innerTypeDoc(t: Type): Doc { + if (t instanceof UnionType) { + const nullable = nullableFromUnion(t); + if (nullable !== null) { + return this.typeDoc(nullable); + } + + return text(this.nameToString(this.nameForNamedType(t))); + } + + return this.typeDoc(t); + } + + // Encode the non-null representation of `t` (the value already unwrapped + // from its `Option`). + private encodeInner(t: Type, value: Doc): Doc { + if (t instanceof UnionType) { + const nullable = nullableFromUnion(t); + if (nullable !== null) { + return this.encodeDoc(nullable, value); + } + + return call(this.encoderName(t), [value]); + } + + return this.encodeDoc(t, value); + } + + private emitClassType(c: ClassType, className: Name): void { + const name = this.nameToString(className); + if (c.getProperties().size === 0) { + this.line(0, `pub type ${name} {`); + this.line(2, name); + this.line(0, "}"); + return; + } + + const fields: Doc[] = []; + this.forEachClassProperty(c, "none", (fieldName, _jsonName, p) => { + const fieldType = this.isGleamOptional(p.type, p.isOptional) + ? call("option.Option", [this.innerTypeDoc(p.type)]) + : this.typeDoc(p.type); + fields.push( + seq(text(`${this.nameToString(fieldName)}: `), fieldType), + ); + }); + + this.line(0, `pub type ${name} {`); + this.emitDoc(2, call(name, fields)); + this.line(0, "}"); + } + + private emitEnumType(e: EnumType, enumName: Name): void { + this.line(0, `pub type ${this.nameToString(enumName)} {`); + this.forEachEnumCase(e, "none", (caseName) => { + this.line(2, this.nameToString(caseName)); + }); + this.line(0, "}"); + } + + private emitUnionType(u: UnionType, unionName: Name): void { + const [, nonNulls] = removeNullFromUnion(u); + this.line(0, `pub type ${this.nameToString(unionName)} {`); + this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { + this.emitDoc( + 2, + call(this.nameToString(memberName), [this.typeDoc(t)]), + ); + }); + this.line(0, "}"); + } + + private emitTopLevelAlias(t: Type, name: Name): void { + // `gleam format` always places an aliased type on its own line. + this.line(0, `pub type ${this.nameToString(name)} =`); + this.emitDoc(2, this.typeDoc(t)); + } + + // Emit a `pub fn name(value: Type) -> Return {` header, breaking the + // parameter list onto its own line when the one-line form exceeds the + // 80-column limit, exactly as `gleam format` does. + private emitFnHeader( + fnName: string, + paramType: string, + returnType: string, + ): void { + // `gleam format` measures the signature width without the trailing + // ` {` block opener. + const signature = `pub fn ${fnName}(value: ${paramType}) -> ${returnType}`; + if (signature.length <= MAX_WIDTH) { + this.line(0, `${signature} {`); + return; + } + + this.line(0, `pub fn ${fnName}(`); + this.line(2, `value: ${paramType},`); + this.line(0, `) -> ${returnType} {`); + } + + private entryDoc(jsonName: string, value: Doc): Doc { + return tuple([text(`"${gleamStringEscape(jsonName)}"`), value]); + } + + private emitClassEncoder(c: ClassType, className: Name): void { + const typeName = this.nameToString(className); + const encoder = this.encoderName(c); + + const required: Doc[] = []; + const optional: Array<{ jsonName: string; name: Name; type: Type }> = + []; + this.forEachClassProperty(c, "none", (name, jsonName, p) => { + if (this.isGleamOptional(p.type, p.isOptional)) { + optional.push({ jsonName, name, type: p.type }); + } else { + required.push( + this.entryDoc( + jsonName, + this.encodeDoc( + p.type, + text(`value.${this.nameToString(name)}`), + ), + ), + ); + } + }); + + this.emitFnHeader(encoder, typeName, "json.Json"); + if (optional.length === 0) { + this.emitDoc(2, hugCall("json.object", list(required))); + } else { + this.emitBinding(2, "let fields", "=", list(required)); + for (const { jsonName, name, type } of optional) { + this.line( + 2, + `let fields = case value.${this.nameToString(name)} {`, + ); + this.emitBinding( + 4, + "option.Some(inner)", + "->", + list( + [ + this.entryDoc( + jsonName, + this.encodeInner(type, text("inner")), + ), + ], + text("fields"), + ), + ); + this.line(4, "option.None -> fields"); + this.line(2, "}"); + } + + this.emitDoc(2, call("json.object", [text("fields")])); + } + + this.line(0, "}"); + } + + private emitEnumEncoder(e: EnumType, enumName: Name): void { + const typeName = this.nameToString(enumName); + const encoder = this.encoderName(e); + this.emitFnHeader(encoder, typeName, "json.Json"); + this.line(2, "case value {"); + this.forEachEnumCase(e, "none", (caseName, jsonName) => { + this.emitBinding( + 4, + this.nameToString(caseName), + "->", + call("json.string", [text(`"${gleamStringEscape(jsonName)}"`)]), + ); + }); + this.line(2, "}"); + this.line(0, "}"); + } + + private emitUnionEncoder(u: UnionType, unionName: Name): void { + const typeName = this.nameToString(unionName); + const encoder = this.encoderName(u); + const [, nonNulls] = removeNullFromUnion(u); + this.emitFnHeader(encoder, typeName, "json.Json"); + this.line(2, "case value {"); + this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { + this.emitBinding( + 4, + `${this.nameToString(memberName)}(inner)`, + "->", + this.encodeDoc(t, text("inner")), + ); + }); + this.line(2, "}"); + this.line(0, "}"); + } + + private emitTopLevelEncoder(t: Type, name: Name): void { + const functions = this._topLevelFunctions.get(name); + if (functions === undefined) { + return; + } + + const typeName = this.nameToString(name); + const encoder = this.nameToString(functions.encoder); + this.emitFnHeader(encoder, typeName, "json.Json"); + this.emitDoc(2, this.encodeDoc(t, text("value"))); + this.line(0, "}"); + } + + private emitJsonValueType(): void { + this.line(0, "pub type JsonValue {"); + this.line(2, "JsonNull"); + this.line(2, "JsonBool(Bool)"); + this.line(2, "JsonInt(Int)"); + this.line(2, "JsonFloat(Float)"); + this.line(2, "JsonString(String)"); + this.line(2, "JsonArray(List(JsonValue))"); + this.line(2, "JsonObject(dict.Dict(String, JsonValue))"); + this.line(0, "}"); + } + + private emitJsonValueEncoder(): void { + this.emitFnHeader("json_value_to_json", "JsonValue", "json.Json"); + this.line(2, "case value {"); + this.line(4, "JsonNull -> json.null()"); + this.line(4, "JsonBool(inner) -> json.bool(inner)"); + this.line(4, "JsonInt(inner) -> json.int(inner)"); + this.line(4, "JsonFloat(inner) -> json.float(inner)"); + this.line(4, "JsonString(inner) -> json.string(inner)"); + this.line( + 4, + "JsonArray(inner) -> json.array(inner, json_value_to_json)", + ); + this.emitDoc( + 4, + seq( + text("JsonObject(inner) -> "), + call("json.dict", [ + text("inner"), + lambda("key", text("key")), + text("json_value_to_json"), + ]), + ), + ); + this.line(2, "}"); + this.line(0, "}"); + } + + private computeNeeds(): void { + for (const t of this.typeGraph.allTypesUnordered()) { + if (t.kind === "any") { + this._needsJsonValue = true; + } + + if (t instanceof ClassType) { + for (const [, p] of t.getProperties()) { + if (this.isGleamOptional(p.type, p.isOptional)) { + this._needsOption = true; + } + } + } + + if (t instanceof UnionType) { + if (nullableFromUnion(t) !== null) { + this._needsOption = true; + } else { + const [hasNull] = removeNullFromUnion(t); + if (hasNull !== null) { + this._needsOption = true; + } + } + } + + if (t.kind === "map") { + this._needsDict = true; + } + } + + // The `JsonObject` variant of `JsonValue` carries a `Dict`. + if (this._needsJsonValue) { + this._needsDict = true; + } + } + protected emitSourceStructure(): void { - this.emitLine("// Generated by quicktype"); - this.ensureBlankLine(); - this.emitLine("import gleam/dynamic/decode"); - this.emitLine("import gleam/json"); + this.computeNeeds(); + + this.line(0, "// Generated by quicktype"); + this.line(0, "//"); + this.line( + 0, + "// Optional and nullable properties both map to `option.Option`,", + ); + this.line( + 0, + '// so `{"a": null}` and `{}` are indistinguishable after decoding.', + ); + this.line(0); + + // Imports must be sorted for `gleam format`. + if (this._needsDict) { + this.line(0, "import gleam/dict"); + } + + this.line(0, "import gleam/json"); + if (this._needsOption) { + this.line(0, "import gleam/option"); + } + + if (this._needsJsonValue) { + this.line(0); + this.emitJsonValueType(); + } + + this.forEachObject("none", (c: ClassType, name) => { + this.line(0); + this.emitClassType(c, name); + }); + this.forEachEnum("none", (e, name) => { + this.line(0); + this.emitEnumType(e, name); + }); + this.forEachUnion("none", (u, name) => { + this.line(0); + this.emitUnionType(u, name); + }); + this.forEachTopLevel( + "none", + (t, name) => { + this.line(0); + this.emitTopLevelAlias(t, name); + }, + (t) => this.namedTypeToNameForTopLevel(t) === undefined, + ); + + if (this._needsJsonValue) { + this.line(0); + this.emitJsonValueEncoder(); + } + + this.forEachObject("none", (c: ClassType, name) => { + this.line(0); + this.emitClassEncoder(c, name); + }); + this.forEachEnum("none", (e, name) => { + this.line(0); + this.emitEnumEncoder(e, name); + }); + this.forEachUnion("none", (u, name) => { + this.line(0); + this.emitUnionEncoder(u, name); + }); + this.forEachTopLevel( + "none", + (t, name) => { + this.line(0); + this.emitTopLevelEncoder(t, name); + }, + (t) => this.namedTypeToNameForTopLevel(t) === undefined, + ); } } diff --git a/packages/quicktype-core/src/language/Gleam/constants.ts b/packages/quicktype-core/src/language/Gleam/constants.ts index 66615e31c2..df10e995d2 100644 --- a/packages/quicktype-core/src/language/Gleam/constants.ts +++ b/packages/quicktype-core/src/language/Gleam/constants.ts @@ -38,3 +38,41 @@ export const forbiddenModuleNames = [ "json", "option", ] as const; + +// Gleam prelude types and value constructors are always in scope. A generated +// type, constructor, or field named after one of these would shadow the +// prelude, breaking `List(...)`, `json.int`, `option.Some`, and friends. +export const preludeNames = [ + "BitArray", + "Bool", + "Float", + "Int", + "List", + "Nil", + "Result", + "String", + "UtfCodepoint", + "Dynamic", + "Ok", + "Error", + "True", + "False", + "Some", + "None", +] as const; + +// Fixed identifiers the renderer emits for the `any` type: the `JsonValue` +// custom type, its constructors, and its conversion functions. User type and +// function names must avoid these so the hand-written definitions win. +export const jsonValueNames = [ + "JsonValue", + "JsonNull", + "JsonBool", + "JsonInt", + "JsonFloat", + "JsonString", + "JsonArray", + "JsonObject", + "json_value_to_json", + "json_value_decoder", +] as const; diff --git a/packages/quicktype-core/src/language/Gleam/language.ts b/packages/quicktype-core/src/language/Gleam/language.ts index 8457cc8ff2..82c4d08b89 100644 --- a/packages/quicktype-core/src/language/Gleam/language.ts +++ b/packages/quicktype-core/src/language/Gleam/language.ts @@ -22,6 +22,17 @@ export class GleamTargetLanguage extends TargetLanguage< return null; } + public get supportsOptionalClassProperties(): boolean { + return true; + } + + // On Erlang, `decode.int` and `decode.float` are disjoint, so a + // number union decodes both without the integer-before-number hazard + // that bites renderers sharing one numeric decoder. + public get supportsUnionsWithBothNumberTypes(): boolean { + return true; + } + protected makeRenderer(renderContext: RenderContext): GleamRenderer { return new GleamRenderer(this, renderContext); } diff --git a/packages/quicktype-core/src/language/Gleam/pretty.ts b/packages/quicktype-core/src/language/Gleam/pretty.ts new file mode 100644 index 0000000000..c1790855be --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/pretty.ts @@ -0,0 +1,256 @@ +// A small Wadler-style pretty-printer that reproduces `gleam format`'s exact +// layout for the expression and type fragments this renderer emits. Matching +// the formatter byte-for-byte is required: the fixture pipeline runs +// `gleam format --check src/` on generated output. +// +// The formatter targets an 80-column line and indents in two-space steps. A +// "group" (a call, list, tuple, or record constructor) renders on one line if +// its flat form fits from the current column; otherwise it breaks, placing one +// element per line indented two spaces past the group's own line, with a +// trailing comma after every element except a list spread (`..rest`). + +export const MAX_WIDTH = 80; +const INDENT = 2; + +export type Doc = + | { kind: "text"; text: string } + | { kind: "seq"; parts: Doc[] } + | { kind: "call"; head: string; args: Doc[] } + | { kind: "hug"; head: string; collection: Doc } + | { kind: "lambda"; param: string; body: Doc } + | { kind: "list"; items: Doc[]; spread: Doc | null } + | { kind: "tuple"; items: Doc[] }; + +export function text(s: string): Doc { + return { kind: "text", text: s }; +} + +export function seq(...parts: Doc[]): Doc { + return { kind: "seq", parts }; +} + +export function call(head: string, args: Doc[]): Doc { + return { kind: "call", head, args }; +} + +// A call whose sole argument is a collection literal. `gleam format` keeps the +// opening delimiter on the call's line (`json.object([`) rather than indenting +// the collection as a separate argument. +export function hugCall(head: string, collection: Doc): Doc { + return { kind: "hug", head, collection }; +} + +export function lambda(param: string, body: Doc): Doc { + return { kind: "lambda", param, body }; +} + +export function list(items: Doc[], spread: Doc | null = null): Doc { + return { kind: "list", items, spread }; +} + +export function tuple(items: Doc[]): Doc { + return { kind: "tuple", items }; +} + +export function isCollection(doc: Doc): boolean { + return doc.kind === "list" || doc.kind === "tuple"; +} + +function pad(n: number): string { + return " ".repeat(n); +} + +export function flat(doc: Doc): string { + switch (doc.kind) { + case "text": + return doc.text; + case "seq": + return doc.parts.map(flat).join(""); + case "call": + return `${doc.head}(${doc.args.map(flat).join(", ")})`; + case "hug": + return `${doc.head}(${flat(doc.collection)})`; + case "lambda": + return `fn(${doc.param}) { ${flat(doc.body)} }`; + case "tuple": + return `#(${doc.items.map(flat).join(", ")})`; + case "list": { + const parts = doc.items.map(flat); + if (doc.spread !== null) { + parts.push(`..${flat(doc.spread)}`); + } + + return `[${parts.join(", ")}]`; + } + } +} + +interface Rendered { + endColumn: number; + text: string; +} + +function endsWithLambda(doc: Doc): boolean { + if (doc.kind === "lambda") { + return true; + } + + if (doc.kind === "call" && doc.args.length > 0) { + return doc.args[doc.args.length - 1].kind === "lambda"; + } + + return false; +} + +// A trailing lambda ends the flat form with a ` }` block-closer that +// `gleam format` treats as "free": such a construct stays on one line at up to +// one column past the normal limit before it breaks. +export function trailingSlack(doc: Doc): number { + return endsWithLambda(doc) ? 1 : 0; +} + +function fits(doc: Doc, column: number): boolean { + return column + flat(doc).length <= MAX_WIDTH + trailingSlack(doc); +} + +// Break a list or tuple onto multiple lines. Items sit at `indent + INDENT`; +// the closing delimiter returns to `indent`. +function renderBrokenItems( + items: Doc[], + spread: Doc | null, + indent: number, + open: string, + close: string, +): string { + const childIndent = indent + INDENT; + let out = `${open}\n`; + for (const item of items) { + out += `${pad(childIndent)}${render(item, childIndent, childIndent).text},\n`; + } + + if (spread !== null) { + out += `${pad(childIndent)}..${render(spread, childIndent, childIndent).text}\n`; + } + + out += `${pad(indent)}${close}`; + return out; +} + +export function render(doc: Doc, indent: number, column: number): Rendered { + switch (doc.kind) { + case "text": + return { text: doc.text, endColumn: column + doc.text.length }; + case "seq": { + let out = ""; + let col = column; + for (const part of doc.parts) { + const rendered = render(part, indent, col); + out += rendered.text; + col = rendered.endColumn; + } + + return { text: out, endColumn: col }; + } + case "call": { + if (fits(doc, column)) { + const flatText = flat(doc); + return { text: flatText, endColumn: column + flatText.length }; + } + + // When the final argument is a lambda, `gleam format` keeps the + // whole argument list (and the lambda's `fn(..) {`) on the opening + // line and breaks only the lambda body. + const lastArg = doc.args[doc.args.length - 1]; + if (lastArg !== undefined && lastArg.kind === "lambda") { + const leading = doc.args.slice(0, -1).map(flat); + const header = [...leading, `fn(${lastArg.param}) {`].join( + ", ", + ); + const bodyIndent = indent + INDENT; + const body = render(lastArg.body, bodyIndent, bodyIndent).text; + const out = `${doc.head}(${header}\n${pad(bodyIndent)}${body}\n${pad(indent)}})`; + return { text: out, endColumn: indent + 2 }; + } + + const childIndent = indent + INDENT; + let out = `${doc.head}(\n`; + for (const arg of doc.args) { + out += `${pad(childIndent)}${render(arg, childIndent, childIndent).text},\n`; + } + + out += `${pad(indent)})`; + return { text: out, endColumn: indent + 1 }; + } + case "lambda": { + if (fits(doc, column)) { + const flatText = flat(doc); + return { text: flatText, endColumn: column + flatText.length }; + } + + const bodyIndent = indent + INDENT; + const body = render(doc.body, bodyIndent, bodyIndent).text; + return { + text: `fn(${doc.param}) {\n${pad(bodyIndent)}${body}\n${pad(indent)}}`, + endColumn: indent + 1, + }; + } + case "hug": { + if (fits(doc, column)) { + const flatText = flat(doc); + return { text: flatText, endColumn: column + flatText.length }; + } + + const collection = doc.collection; + const inner = + collection.kind === "list" + ? renderBrokenItems( + collection.items, + collection.spread, + indent, + "[", + "]", + ) + : collection.kind === "tuple" + ? renderBrokenItems( + collection.items, + null, + indent, + "#(", + ")", + ) + : render(collection, indent, column).text; + return { + text: `${doc.head}(${inner})`, + endColumn: indent + 1, + }; + } + case "tuple": { + if (fits(doc, column)) { + const flatText = flat(doc); + return { text: flatText, endColumn: column + flatText.length }; + } + + return { + text: renderBrokenItems(doc.items, null, indent, "#(", ")"), + endColumn: indent + 1, + }; + } + case "list": { + if (fits(doc, column)) { + const flatText = flat(doc); + return { text: flatText, endColumn: column + flatText.length }; + } + + return { + text: renderBrokenItems( + doc.items, + doc.spread, + indent, + "[", + "]", + ), + endColumn: indent + 1, + }; + } + } +} diff --git a/packages/quicktype-core/src/language/Gleam/utils.ts b/packages/quicktype-core/src/language/Gleam/utils.ts index be7e10f9fe..e283a97b4c 100644 --- a/packages/quicktype-core/src/language/Gleam/utils.ts +++ b/packages/quicktype-core/src/language/Gleam/utils.ts @@ -22,12 +22,17 @@ function isAsciiLetterOrUnderscoreOrDigit(codePoint: number): boolean { return isLetterOrUnderscoreOrDigit(codePoint); } -function isAsciiLetterOrUnderscore(codePoint: number): boolean { +// Gleam identifiers must begin with a letter — a leading underscore marks a +// discard and is not valid for a type, constructor, field, or function name. +// So, unlike most targets, underscore is excluded from the start-character +// set, forcing `combineWords` to prepend "the" when a name would otherwise +// start with a digit or underscore. +function isAsciiLetter(codePoint: number): boolean { if (!isAscii(codePoint)) { return false; } - return isLetterOrUnderscore(codePoint); + return isLetterOrUnderscore(codePoint) && codePoint !== 0x5f; } const legalizeName = legalizeCharacters(isAsciiLetterOrUnderscoreOrDigit); @@ -37,7 +42,7 @@ function gleamStyle(original: string, isSnakeCase: boolean): string { const wordStyle = isSnakeCase ? allLowerWordStyle : firstUpperWordStyle; - const combined = combineWords( + return combineWords( words, legalizeName, wordStyle, @@ -45,10 +50,8 @@ function gleamStyle(original: string, isSnakeCase: boolean): string { wordStyle, wordStyle, isSnakeCase ? "_" : "", - isAsciiLetterOrUnderscore, + isAsciiLetter, ); - - return combined === "_" ? "_underscore" : combined; } // snake_case, for functions, record fields, and local values. From 2d52d888b660f38cd3f71b229e1fc1f434556ba5 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 16 Sep 2026 23:16:31 +0000 Subject: [PATCH 04/19] feat(gleam): emit decoders Emit one `_decoder() -> decode.Decoder(T)` per type using the `gleam/dynamic/decode` API. Every decoder opens with `use <- decode.recursive` so a self-referencing decoder cannot loop at construction; a later commit narrows the guard to cycle breakers. Optional-or-null fields use `decode.optional_field` with `decode.optional`, and the `JsonValue` catch-all is emitted last in `decode.one_of`. `number` fields accept an int and widen it, since Erlang's int and float decoders are disjoint. Extend the pretty-printer to reproduce `gleam format`'s hug rules for constructor calls, trailing lambdas, and `one_of`. Add unit tests for the `decode.recursive` guard and the `json_value_decoder` catch-all ordering. --- .../src/language/Gleam/GleamRenderer.ts | 306 +++++++++++++++++- .../src/language/Gleam/pretty.ts | 211 ++++++------ test/unit/gleam-decoders.test.ts | 83 +++++ 3 files changed, 477 insertions(+), 123 deletions(-) create mode 100644 test/unit/gleam-decoders.test.ts diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index bc7cc5c010..fa4085ec1f 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -3,8 +3,6 @@ import { type ForbiddenWordsInfo, } from "../../ConvenienceRenderer.js"; import { DependencyName, type Name, type Namer } from "../../Naming.js"; -import type { RenderContext } from "../../Renderer.js"; -import type { TargetLanguage } from "../../TargetLanguage.js"; import { matchType, nullableFromUnion, @@ -28,13 +26,13 @@ import { MAX_WIDTH, call, flat, - hugCall, isCollection, lambda, list, render, seq, text, + trailingSlack, tuple, } from "./pretty.js"; import { @@ -59,12 +57,7 @@ export class GleamRenderer extends ConvenienceRenderer { private _needsDict = false; - public constructor( - targetLanguage: TargetLanguage, - renderContext: RenderContext, - ) { - super(targetLanguage, renderContext); - } + private _needsInt = false; protected makeNamedTypeNamer(): Namer { return pascalNamingFunction; @@ -186,13 +179,21 @@ export class GleamRenderer extends ConvenienceRenderer { } private encoderName(t: Type): string { + return this.nameToString(this.functionsForType(t).encoder); + } + + private decoderName(t: Type): string { + return this.nameToString(this.functionsForType(t).decoder); + } + + private functionsForType(t: Type): NamedTypeFunctions { const name = this.nameForNamedType(t); const functions = this._namedTypeFunctions.get(name); if (functions === undefined) { throw new Error(`No conversion functions for type ${name}`); } - return this.nameToString(functions.encoder); + return functions; } // Manual, absolute-column line emission. The renderer keeps its own @@ -427,6 +428,86 @@ export class GleamRenderer extends ConvenienceRenderer { return this.encodeDoc(t, value); } + // A `decode.Decoder(T)` value for `t`. + private decoderDoc(t: Type): Doc { + return matchType( + t, + (_anyType) => call("json_value_decoder", []), + (_nullType) => call("decode.success", [text("Nil")]), + (_boolType) => text("decode.bool"), + (_integerType) => text("decode.int"), + // On Erlang `decode.float` rejects an integral JSON number, so a + // `number` field must accept an int and widen it. + (_doubleType) => + call("decode.one_of", [ + text("decode.float"), + list([ + call("decode.map", [ + text("decode.int"), + text("int.to_float"), + ]), + ]), + ]), + (_stringType) => text("decode.string"), + (arrayType) => + call("decode.list", [this.decoderDoc(arrayType.items)]), + (classType) => call(this.decoderName(classType), []), + (mapType) => + call("decode.dict", [ + text("decode.string"), + this.decoderDoc(mapType.values), + ]), + (enumType) => call(this.decoderName(enumType), []), + (unionType) => { + const nullable = nullableFromUnion(unionType); + if (nullable !== null) { + return call("decode.optional", [this.decoderDoc(nullable)]); + } + + const [hasNull] = removeNullFromUnion(unionType); + if (hasNull !== null) { + return call("decode.optional", [ + call(this.decoderName(unionType), []), + ]); + } + + return call(this.decoderName(unionType), []); + }, + ); + } + + // The non-null decoder for a property's inner type (the value inside its + // `Option`). + private decoderInner(t: Type): Doc { + if (t instanceof UnionType) { + const nullable = nullableFromUnion(t); + if (nullable !== null) { + return this.decoderDoc(nullable); + } + + return call(this.decoderName(t), []); + } + + return this.decoderDoc(t); + } + + // Emit a `use <- ` line, breaking the call in place when + // it overflows (its arguments break; the `use ... <-` prefix stays put). + private emitUse(indent: number, binding: string, callDoc: Doc): void { + const prefix = binding === "" ? "use <- " : `use ${binding} <- `; + const flatLine = `${prefix}${flat(callDoc)}`; + if (indent + flatLine.length <= MAX_WIDTH + trailingSlack(callDoc)) { + this.line(indent, flatLine); + return; + } + + const rendered = render(callDoc, indent, indent + prefix.length); + const full = `${" ".repeat(indent)}${prefix}${rendered.text}`; + for (const physicalLine of full.split("\n")) { + this.emitLine(physicalLine); + } + } + private emitClassType(c: ClassType, className: Name): void { const name = this.nameToString(className); if (c.getProperties().size === 0) { @@ -527,7 +608,7 @@ export class GleamRenderer extends ConvenienceRenderer { this.emitFnHeader(encoder, typeName, "json.Json"); if (optional.length === 0) { - this.emitDoc(2, hugCall("json.object", list(required))); + this.emitDoc(2, call("json.object", [list(required)])); } else { this.emitBinding(2, "let fields", "=", list(required)); for (const { jsonName, name, type } of optional) { @@ -646,6 +727,173 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "}"); } + // Emit a `pub fn name() -> decode.Decoder(Type) {` header, breaking the + // return type when the one-line form exceeds 80 columns. + private emitDecoderHeader(fnName: string, typeName: string): void { + const signature = `pub fn ${fnName}() -> decode.Decoder(${typeName})`; + if (signature.length <= MAX_WIDTH) { + this.line(0, `${signature} {`); + return; + } + + this.line(0, `pub fn ${fnName}() -> decode.Decoder(`); + this.line(2, `${typeName},`); + this.line(0, ") {"); + } + + private emitClassDecoder(c: ClassType, className: Name): void { + const typeName = this.nameToString(className); + this.emitDecoderHeader(this.decoderName(c), typeName); + // Rule 1: every decoder opens with `decode.recursive`. Omitting it on a + // decoder that reaches itself compiles clean, then hangs forever at + // construction time. Applying it uniformly is harmless and safe. + this.line(2, "use <- decode.recursive"); + + const fields: Doc[] = []; + this.forEachClassProperty(c, "none", (name, jsonName, p) => { + const binding = this.nameToString(name); + const key = `"${gleamStringEscape(jsonName)}"`; + if (this.isGleamOptional(p.type, p.isOptional)) { + // Rule 2: a field that may be absent *or* null needs both + // `optional_field` (missing key) and `optional` (null value). + this.emitUse( + 2, + binding, + call("decode.optional_field", [ + text(key), + text("option.None"), + call("decode.optional", [this.decoderInner(p.type)]), + ]), + ); + } else { + this.emitUse( + 2, + binding, + call("decode.field", [text(key), this.decoderDoc(p.type)]), + ); + } + + fields.push(text(`${binding}: ${binding}`)); + }); + + const constructed = + fields.length === 0 ? text(typeName) : call(typeName, fields); + this.emitDoc(2, call("decode.success", [constructed])); + this.line(0, "}"); + } + + private emitEnumDecoder(e: EnumType, enumName: Name): void { + const typeName = this.nameToString(enumName); + this.emitDecoderHeader(this.decoderName(e), typeName); + this.line(2, "use <- decode.recursive"); + this.emitUse( + 2, + "variant", + call("decode.then", [text("decode.string")]), + ); + + const cases: Array<{ jsonName: string; name: Name }> = []; + this.forEachEnumCase(e, "none", (name, jsonName) => { + cases.push({ jsonName, name }); + }); + + this.line(2, "case variant {"); + for (const { jsonName, name } of cases) { + this.emitBinding( + 4, + `"${gleamStringEscape(jsonName)}"`, + "->", + call("decode.success", [text(this.nameToString(name))]), + ); + } + + const placeholder = this.nameToString(cases[0].name); + this.emitBinding( + 4, + "_", + "->", + call("decode.failure", [text(placeholder), text(`"${typeName}"`)]), + ); + this.line(2, "}"); + this.line(0, "}"); + } + + private emitUnionDecoder(u: UnionType, unionName: Name): void { + const typeName = this.nameToString(unionName); + const [, nonNulls] = removeNullFromUnion(u); + this.emitDecoderHeader(this.decoderName(u), typeName); + this.line(2, "use <- decode.recursive"); + + const members: Doc[] = []; + let anyMember: Doc | undefined; + this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { + const variant = call("decode.map", [ + this.decoderDoc(t), + text(this.nameToString(memberName)), + ]); + // Rule 3: `JsonValue` matches every input, so its variant must come + // last or it would shadow the others. + if (t.kind === "any") { + anyMember = variant; + } else { + members.push(variant); + } + }); + + if (anyMember !== undefined) { + members.push(anyMember); + } + + this.emitOneOf(members); + this.line(0, "}"); + } + + private emitOneOf(members: Doc[]): void { + this.emitDoc( + 2, + call("decode.one_of", [members[0], list(members.slice(1))]), + ); + } + + private emitTopLevelDecoder(t: Type, name: Name): void { + const functions = this._topLevelFunctions.get(name); + if (functions === undefined) { + return; + } + + this.emitDecoderHeader( + this.nameToString(functions.decoder), + this.nameToString(name), + ); + this.line(2, "use <- decode.recursive"); + this.emitDoc(2, this.decoderDoc(t)); + this.line(0, "}"); + } + + private emitJsonValueDecoder(): void { + this.emitDecoderHeader("json_value_decoder", "JsonValue"); + this.line(2, "use <- decode.recursive"); + this.emitOneOf([ + call("decode.map", [text("decode.bool"), text("JsonBool")]), + call("decode.map", [text("decode.int"), text("JsonInt")]), + call("decode.map", [text("decode.float"), text("JsonFloat")]), + call("decode.map", [text("decode.string"), text("JsonString")]), + call("decode.map", [ + call("decode.list", [call("json_value_decoder", [])]), + text("JsonArray"), + ]), + call("decode.map", [ + call("decode.dict", [ + text("decode.string"), + call("json_value_decoder", []), + ]), + text("JsonObject"), + ]), + call("decode.success", [text("JsonNull")]), + ]); + this.line(0, "}"); + } + private computeNeeds(): void { for (const t of this.typeGraph.allTypesUnordered()) { if (t.kind === "any") { @@ -674,6 +922,11 @@ export class GleamRenderer extends ConvenienceRenderer { if (t.kind === "map") { this._needsDict = true; } + + // The `number` decoder widens an int with `int.to_float`. + if (t.kind === "double") { + this._needsInt = true; + } } // The `JsonObject` variant of `JsonValue` carries a `Dict`. @@ -702,6 +955,11 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "import gleam/dict"); } + this.line(0, "import gleam/dynamic/decode"); + if (this._needsInt) { + this.line(0, "import gleam/int"); + } + this.line(0, "import gleam/json"); if (this._needsOption) { this.line(0, "import gleam/option"); @@ -758,5 +1016,31 @@ export class GleamRenderer extends ConvenienceRenderer { }, (t) => this.namedTypeToNameForTopLevel(t) === undefined, ); + + if (this._needsJsonValue) { + this.line(0); + this.emitJsonValueDecoder(); + } + + this.forEachObject("none", (c: ClassType, name) => { + this.line(0); + this.emitClassDecoder(c, name); + }); + this.forEachEnum("none", (e, name) => { + this.line(0); + this.emitEnumDecoder(e, name); + }); + this.forEachUnion("none", (u, name) => { + this.line(0); + this.emitUnionDecoder(u, name); + }); + this.forEachTopLevel( + "none", + (t, name) => { + this.line(0); + this.emitTopLevelDecoder(t, name); + }, + (t) => this.namedTypeToNameForTopLevel(t) === undefined, + ); } } diff --git a/packages/quicktype-core/src/language/Gleam/pretty.ts b/packages/quicktype-core/src/language/Gleam/pretty.ts index c1790855be..6ef2428fd5 100644 --- a/packages/quicktype-core/src/language/Gleam/pretty.ts +++ b/packages/quicktype-core/src/language/Gleam/pretty.ts @@ -3,11 +3,13 @@ // the formatter byte-for-byte is required: the fixture pipeline runs // `gleam format --check src/` on generated output. // -// The formatter targets an 80-column line and indents in two-space steps. A -// "group" (a call, list, tuple, or record constructor) renders on one line if -// its flat form fits from the current column; otherwise it breaks, placing one -// element per line indented two spaces past the group's own line, with a -// trailing comma after every element except a list spread (`..rest`). +// The formatter targets an 80-column line and indents in two-space steps. When +// a call, list, tuple, or constructor does not fit on one line it breaks. If +// its *last* argument is itself "huggable" — a collection, a lambda, or a +// nested call — the earlier arguments and that argument's opening delimiter +// stay on the line and only the trailing argument's body breaks (`json.object([`, +// `decode.success(Big(`, `json.array(v, fn(v) {`). Otherwise every argument +// moves to its own line, each with a trailing comma. export const MAX_WIDTH = 80; const INDENT = 2; @@ -16,7 +18,6 @@ export type Doc = | { kind: "text"; text: string } | { kind: "seq"; parts: Doc[] } | { kind: "call"; head: string; args: Doc[] } - | { kind: "hug"; head: string; collection: Doc } | { kind: "lambda"; param: string; body: Doc } | { kind: "list"; items: Doc[]; spread: Doc | null } | { kind: "tuple"; items: Doc[] }; @@ -33,13 +34,6 @@ export function call(head: string, args: Doc[]): Doc { return { kind: "call", head, args }; } -// A call whose sole argument is a collection literal. `gleam format` keeps the -// opening delimiter on the call's line (`json.object([`) rather than indenting -// the collection as a separate argument. -export function hugCall(head: string, collection: Doc): Doc { - return { kind: "hug", head, collection }; -} - export function lambda(param: string, body: Doc): Doc { return { kind: "lambda", param, body }; } @@ -56,6 +50,18 @@ export function isCollection(doc: Doc): boolean { return doc.kind === "list" || doc.kind === "tuple"; } +// Whether `gleam format` keeps this document attached to the opening line of +// an enclosing call when that call breaks. Collections and lambdas always hug; +// a nested call hugs only when it is a constructor application (a capitalized +// head such as `Big(` or `FooString(`), not a plain function call. +function isHuggable(doc: Doc): boolean { + if (doc.kind === "list" || doc.kind === "tuple" || doc.kind === "lambda") { + return true; + } + + return doc.kind === "call" && /^[A-Z]/.test(doc.head); +} + function pad(n: number): string { return " ".repeat(n); } @@ -68,8 +74,6 @@ export function flat(doc: Doc): string { return doc.parts.map(flat).join(""); case "call": return `${doc.head}(${doc.args.map(flat).join(", ")})`; - case "hug": - return `${doc.head}(${flat(doc.collection)})`; case "lambda": return `fn(${doc.param}) { ${flat(doc.body)} }`; case "tuple": @@ -90,30 +94,40 @@ interface Rendered { text: string; } -function endsWithLambda(doc: Doc): boolean { - if (doc.kind === "lambda") { - return true; - } - +// A call whose last argument is huggable can break by hugging that argument, +// which moves its closing delimiter onto a continuation line. `gleam format` +// treats that delimiter as "free", so such a call stays on one line at up to +// one column past the normal limit before it breaks. +export function trailingSlack(doc: Doc): number { if (doc.kind === "call" && doc.args.length > 0) { - return doc.args[doc.args.length - 1].kind === "lambda"; + return isHuggable(doc.args[doc.args.length - 1]) ? 1 : 0; } - return false; -} - -// A trailing lambda ends the flat form with a ` }` block-closer that -// `gleam format` treats as "free": such a construct stays on one line at up to -// one column past the normal limit before it breaks. -export function trailingSlack(doc: Doc): number { - return endsWithLambda(doc) ? 1 : 0; + return 0; } function fits(doc: Doc, column: number): boolean { return column + flat(doc).length <= MAX_WIDTH + trailingSlack(doc); } -// Break a list or tuple onto multiple lines. Items sit at `indent + INDENT`; +// The opening delimiter of a huggable document — what appears on the enclosing +// line when the document hugs. +function openToken(doc: Doc): string { + switch (doc.kind) { + case "list": + return "["; + case "tuple": + return "#("; + case "lambda": + return `fn(${doc.param}) {`; + case "call": + return `${doc.head}(`; + default: + return ""; + } +} + +// Render a list or tuple broken across lines. Items sit at `indent + INDENT`; // the closing delimiter returns to `indent`. function renderBrokenItems( items: Doc[], @@ -136,6 +150,56 @@ function renderBrokenItems( return out; } +// Force `doc` into its broken (multi-line) form. `column` is where the opening +// delimiter sits, needed only for a nested call's own hug decision. +function renderBroken(doc: Doc, indent: number, column: number): string { + switch (doc.kind) { + case "list": + return renderBrokenItems(doc.items, doc.spread, indent, "[", "]"); + case "tuple": + return renderBrokenItems(doc.items, null, indent, "#(", ")"); + case "lambda": { + const bodyIndent = indent + INDENT; + const body = render(doc.body, bodyIndent, bodyIndent).text; + return `fn(${doc.param}) {\n${pad(bodyIndent)}${body}\n${pad(indent)}}`; + } + case "call": + return renderCallBroken(doc, indent, column); + default: + return render(doc, indent, column).text; + } +} + +function renderCallBroken( + doc: Doc & { kind: "call" }, + indent: number, + column: number, +): string { + const lastArg = doc.args[doc.args.length - 1]; + if (lastArg !== undefined && isHuggable(lastArg)) { + const leading = doc.args.slice(0, -1).map(flat); + const prefix = `${doc.head}(${leading.length > 0 ? `${leading.join(", ")}, ` : ""}`; + // Hug only when the leading arguments and the trailing argument's + // opening delimiter fit on the line; otherwise every argument breaks. + // The hug frees the trailing closer, so one column of slack applies. + if ( + column + prefix.length + openToken(lastArg).length <= + MAX_WIDTH + 1 + ) { + return `${prefix}${renderBroken(lastArg, indent, column + prefix.length)})`; + } + } + + const childIndent = indent + INDENT; + let out = `${doc.head}(\n`; + for (const arg of doc.args) { + out += `${pad(childIndent)}${render(arg, childIndent, childIndent).text},\n`; + } + + out += `${pad(indent)})`; + return out; +} + export function render(doc: Doc, indent: number, column: number): Rendered { switch (doc.kind) { case "text": @@ -157,84 +221,13 @@ export function render(doc: Doc, indent: number, column: number): Rendered { return { text: flatText, endColumn: column + flatText.length }; } - // When the final argument is a lambda, `gleam format` keeps the - // whole argument list (and the lambda's `fn(..) {`) on the opening - // line and breaks only the lambda body. - const lastArg = doc.args[doc.args.length - 1]; - if (lastArg !== undefined && lastArg.kind === "lambda") { - const leading = doc.args.slice(0, -1).map(flat); - const header = [...leading, `fn(${lastArg.param}) {`].join( - ", ", - ); - const bodyIndent = indent + INDENT; - const body = render(lastArg.body, bodyIndent, bodyIndent).text; - const out = `${doc.head}(${header}\n${pad(bodyIndent)}${body}\n${pad(indent)}})`; - return { text: out, endColumn: indent + 2 }; - } - - const childIndent = indent + INDENT; - let out = `${doc.head}(\n`; - for (const arg of doc.args) { - out += `${pad(childIndent)}${render(arg, childIndent, childIndent).text},\n`; - } - - out += `${pad(indent)})`; - return { text: out, endColumn: indent + 1 }; - } - case "lambda": { - if (fits(doc, column)) { - const flatText = flat(doc); - return { text: flatText, endColumn: column + flatText.length }; - } - - const bodyIndent = indent + INDENT; - const body = render(doc.body, bodyIndent, bodyIndent).text; return { - text: `fn(${doc.param}) {\n${pad(bodyIndent)}${body}\n${pad(indent)}}`, - endColumn: indent + 1, - }; - } - case "hug": { - if (fits(doc, column)) { - const flatText = flat(doc); - return { text: flatText, endColumn: column + flatText.length }; - } - - const collection = doc.collection; - const inner = - collection.kind === "list" - ? renderBrokenItems( - collection.items, - collection.spread, - indent, - "[", - "]", - ) - : collection.kind === "tuple" - ? renderBrokenItems( - collection.items, - null, - indent, - "#(", - ")", - ) - : render(collection, indent, column).text; - return { - text: `${doc.head}(${inner})`, - endColumn: indent + 1, - }; - } - case "tuple": { - if (fits(doc, column)) { - const flatText = flat(doc); - return { text: flatText, endColumn: column + flatText.length }; - } - - return { - text: renderBrokenItems(doc.items, null, indent, "#(", ")"), - endColumn: indent + 1, + text: renderCallBroken(doc, indent, column), + endColumn: indent + 2, }; } + case "lambda": + case "tuple": case "list": { if (fits(doc, column)) { const flatText = flat(doc); @@ -242,13 +235,7 @@ export function render(doc: Doc, indent: number, column: number): Rendered { } return { - text: renderBrokenItems( - doc.items, - doc.spread, - indent, - "[", - "]", - ), + text: renderBroken(doc, indent, column), endColumn: indent + 1, }; } diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts new file mode 100644 index 0000000000..5e1f711229 --- /dev/null +++ b/test/unit/gleam-decoders.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; + +import { + InputData, + JSONSchemaInput, + jsonInputForTargetLanguage, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +async function gleamFromJSON(name: string, samples: string[]): Promise { + const jsonInput = jsonInputForTargetLanguage("gleam"); + await jsonInput.addSource({ name, samples }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ inputData, lang: "gleam" }); + return result.lines.join("\n"); +} + +async function gleamFromSchema(schema: 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: "gleam" }); + return result.lines.join("\n"); +} + +// Rule 1 (highest severity): a decoder that reaches itself and omits +// `decode.recursive` compiles clean, then hangs forever at construction time. +// Fixture tests cannot catch this — the job simply times out — so assert the +// guard on every generated decoder here. +test("every Gleam decoder opens with `use <- decode.recursive`", async () => { + const output = await gleamFromJSON("Tree", [ + JSON.stringify({ + value: 1, + label: "root", + children: [{ value: 2, label: "leaf", children: [] }], + }), + ]); + + const lines = output.split("\n"); + const headerIndices = lines + .map((line, index) => ({ line, index })) + .filter(({ line }) => /^pub fn \w+_decoder\(/.test(line)) + .map(({ index }) => index); + + expect(headerIndices.length).toBeGreaterThan(0); + for (const headerIndex of headerIndices) { + // A header may span multiple lines when its return type is long; the + // body starts on the line after the one ending in `{`. + let bodyIndex = headerIndex; + while (!lines[bodyIndex].endsWith("{")) { + bodyIndex += 1; + } + + expect(lines[bodyIndex + 1].trim()).toBe("use <- decode.recursive"); + } +}); + +// Rule 3: `JsonValue` matches every input, so the variant that decodes to it +// must come last in a `one_of` or it would shadow the others. The bare `any` +// member is absorbed by the type IR, but the same "universal match last" +// invariant is realized inside `json_value_decoder`, whose `decode.success` +// catch-all always succeeds and therefore must be the final alternative. +test("the universal `JsonValue` catch-all is emitted last", async () => { + const output = await gleamFromSchema( + JSON.stringify({ type: ["string", "integer", "array", "object"] }), + ); + + const start = output.indexOf("pub fn json_value_decoder()"); + expect(start).toBeGreaterThanOrEqual(0); + const decoder = output.slice(start, output.indexOf("\n}", start)); + + const catchAll = decoder.indexOf("decode.success(JsonNull)"); + const listVariant = decoder.indexOf("JsonArray"); + const dictVariant = decoder.indexOf("JsonObject"); + expect(catchAll).toBeGreaterThan(listVariant); + expect(catchAll).toBeGreaterThan(dictVariant); + + // The catch-all is the final alternative: nothing follows it but the + // closing delimiters of the `one_of`. + expect(decoder.slice(catchAll)).not.toContain("decode.map"); +}); From 9f1fc85f7ca37a4eada4a4a6188d54ffb910bc20 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 16 Sep 2026 23:19:00 +0000 Subject: [PATCH 05/19] test(gleam): add fixture driver Add test/fixtures/gleam/: a `main`-named Gleam project whose driver reads a JSON file path from argv, decodes it into TopLevel with the generated decoder, re-encodes to stdout, and exits non-zero on decode failure (the whole assertion for `.fail.json` samples). Commit manifest.toml with pinned dependency versions so a Hex release cannot break an unrelated PR, and gitignore the build/ directory. Verified end-to-end against a copied fixture project: `gleam deps download`, `gleam build`, `gleam format --check src/`, a round-trip run that reproduces pokedex, and a non-zero exit on an expected-failure sample. --- test/fixtures/gleam/.gitignore | 1 + test/fixtures/gleam/gleam.toml | 11 +++++++++ test/fixtures/gleam/manifest.toml | 18 +++++++++++++++ test/fixtures/gleam/src/main.gleam | 36 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 test/fixtures/gleam/.gitignore create mode 100644 test/fixtures/gleam/gleam.toml create mode 100644 test/fixtures/gleam/manifest.toml create mode 100644 test/fixtures/gleam/src/main.gleam diff --git a/test/fixtures/gleam/.gitignore b/test/fixtures/gleam/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/test/fixtures/gleam/.gitignore @@ -0,0 +1 @@ +/build diff --git a/test/fixtures/gleam/gleam.toml b/test/fixtures/gleam/gleam.toml new file mode 100644 index 0000000000..5a5ef7c692 --- /dev/null +++ b/test/fixtures/gleam/gleam.toml @@ -0,0 +1,11 @@ +name = "main" +version = "1.0.0" + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" +argv = ">= 1.1.0 and < 2.0.0" +simplifile = ">= 2.7.0 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/test/fixtures/gleam/manifest.toml b/test/fixtures/gleam/manifest.toml new file mode 100644 index 0000000000..a1890e6b84 --- /dev/null +++ b/test/fixtures/gleam/manifest.toml @@ -0,0 +1,18 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_stdlib", version = "1.0.5", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "CEE5B6C076A85B45F60C585F4316C63EC8B7127C119D5738C3958A9C4D50404E" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, +] + +[requirements] +argv = { version = ">= 1.1.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +simplifile = { version = ">= 2.7.0 and < 3.0.0" } diff --git a/test/fixtures/gleam/src/main.gleam b/test/fixtures/gleam/src/main.gleam new file mode 100644 index 0000000000..4c7ea3d055 --- /dev/null +++ b/test/fixtures/gleam/src/main.gleam @@ -0,0 +1,36 @@ +// Round-trip driver for the quicktype Gleam fixture tests. +// +// Reads a JSON file path from argv, decodes it into `TopLevel` with the +// generated decoder, re-encodes it, and prints the result to stdout. A decode +// failure exits non-zero — that exit code is the whole assertion for +// expected-failure (`*.fail.json`) samples. + +import argv +import gleam/io +import gleam/json +import quicktype +import simplifile + +pub fn main() { + case argv.load().arguments { + [path, ..] -> run(path) + _ -> { + io.println_error("usage: main ") + halt(2) + } + } +} + +fn run(path: String) -> a { + let assert Ok(content) = simplifile.read(path) + case json.parse(content, quicktype.top_level_decoder()) { + Ok(value) -> { + io.println(json.to_string(quicktype.top_level_to_json(value))) + halt(0) + } + Error(_) -> halt(1) + } +} + +@external(erlang, "erlang", "halt") +fn halt(code: Int) -> a From 6beef9d69c62a9d178eb1edf4b748cc39810b032 Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 17 Sep 2026 00:16:33 +0000 Subject: [PATCH 06/19] test(gleam): register fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register GleamLanguage in test/languages.ts and add JSONFixture and JSONSchemaFixture entries in test/fixtures.ts (name "gleam" / "schema-gleam"). Work around a gleam-compiler flake: its BEAM backend segfaults at a low rate when compiling large generated modules, much more often when many fixture workers compile in parallel. Precompile dependencies in setup against a committed placeholder src/quicktype.gleam, and serialize each per-sample `gleam build` with a shared flock, so module compilations never overlap. Skip lists, each justified inline: - skipJSON: keywords/nst-test-suite (module too large — gleam segfaults even single-process), blns-object (Erlang backend miscompiles a tab+emoji key), nbl-stats (gleam crashes on this module a few percent of the time even for an isolated build). - skipSchema: keyword-unions (~17k-line module the compiler segfaults on). - skipDiffViaSchema: bug427/github-events/recursive (JSON vs JSON-Schema paths name types/fields/enum cases differently; output is correct either way). Fix two decoder-strictness bugs surfaced by expected-failure samples: an optional (absent-allowed) non-nullable field must still reject an explicit null, and a property-less record must require a JSON object rather than accepting any value. Verified: `QUICKTEST=true FIXTURE=gleam,schema-gleam npm run test:fixtures` passes across four consecutive runs. --- .../src/language/Gleam/GleamRenderer.ts | 87 +++++++++++++------ test/fixtures.ts | 2 + test/fixtures/gleam/src/quicktype.gleam | 20 +++++ test/languages.ts | 52 +++++++++++ 4 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 test/fixtures/gleam/src/quicktype.gleam diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index fa4085ec1f..daf3f6bded 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -381,22 +381,24 @@ export class GleamRenderer extends ConvenienceRenderer { ); } - // Whether a class property is represented as `option.Option(_)` in Gleam. - // Both absent (optional) and null (nullable) properties collapse to - // `Option`. - private isGleamOptional(t: Type, isOptional: boolean): boolean { + // Whether a type admits a JSON null (a `T | null` union). + private isNullableType(t: Type): boolean { if (t instanceof UnionType) { if (nullableFromUnion(t) !== null) { return true; } const [hasNull] = removeNullFromUnion(t); - if (hasNull !== null) { - return true; - } + return hasNull !== null; } - return isOptional; + return false; + } + + // Whether a class property is represented as `option.Option(_)` in Gleam. + // Both absent (optional) and null (nullable) properties map to `Option`. + private isGleamOptional(t: Type, isOptional: boolean): boolean { + return isOptional || this.isNullableType(t); } // The non-null Gleam type wrapped inside a property's `Option(_)`. @@ -752,33 +754,62 @@ export class GleamRenderer extends ConvenienceRenderer { const fields: Doc[] = []; this.forEachClassProperty(c, "none", (name, jsonName, p) => { const binding = this.nameToString(name); - const key = `"${gleamStringEscape(jsonName)}"`; - if (this.isGleamOptional(p.type, p.isOptional)) { - // Rule 2: a field that may be absent *or* null needs both - // `optional_field` (missing key) and `optional` (null value). - this.emitUse( - 2, - binding, - call("decode.optional_field", [ - text(key), - text("option.None"), - call("decode.optional", [this.decoderInner(p.type)]), + const key = text(`"${gleamStringEscape(jsonName)}"`); + const nullable = this.isNullableType(p.type); + // Absence and null are handled by separate combinators, so a field + // that admits only one of them must reject the other: + // - `optional_field` accepts a missing key (default `None`). + // - `optional` accepts a null value (mapping it to `None`). + // - `map(_, Some)` demands a present, non-null value. + let decoder: Doc; + if (!p.isOptional && !nullable) { + decoder = call("decode.field", [key, this.decoderDoc(p.type)]); + } else if (!p.isOptional && nullable) { + // Required key, nullable value. + decoder = call("decode.field", [ + key, + call("decode.optional", [this.decoderInner(p.type)]), + ]); + } else if (p.isOptional && !nullable) { + // Optional key, non-null value — reject an explicit null. + decoder = call("decode.optional_field", [ + key, + text("option.None"), + call("decode.map", [ + this.decoderInner(p.type), + text("option.Some"), ]), - ); + ]); } else { - this.emitUse( - 2, - binding, - call("decode.field", [text(key), this.decoderDoc(p.type)]), - ); + // Optional key, nullable value — Rule 2: both combinators. + decoder = call("decode.optional_field", [ + key, + text("option.None"), + call("decode.optional", [this.decoderInner(p.type)]), + ]); } + this.emitUse(2, binding, decoder); fields.push(text(`${binding}: ${binding}`)); }); - const constructed = - fields.length === 0 ? text(typeName) : call(typeName, fields); - this.emitDoc(2, call("decode.success", [constructed])); + if (fields.length === 0) { + // A property-less record still models a JSON object, so require one + // (rather than `decode.success`, which would accept any value). + this.emitDoc( + 2, + call("decode.map", [ + call("decode.dict", [ + text("decode.string"), + text("decode.dynamic"), + ]), + lambda("_", text(typeName)), + ]), + ); + } else { + this.emitDoc(2, call("decode.success", [call(typeName, fields)])); + } + this.line(0, "}"); } diff --git a/test/fixtures.ts b/test/fixtures.ts index 161d183f0c..66b518f7eb 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1701,6 +1701,7 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.PikeLanguage), new JSONFixture(languages.HaskellLanguage), new JSONFixture(languages.ElixirLanguage), + new JSONFixture(languages.GleamLanguage), new JSONFixture(languages.JavaScriptPropTypesLanguage), new JSONSchemaJSONFixture(languages.CSharpLanguage), new JSONTypeScriptFixture(languages.CSharpLanguage), @@ -1757,6 +1758,7 @@ export const allFixtures: Fixture[] = [ new JSONSchemaFixture(languages.PikeLanguage), new JSONSchemaFixture(languages.HaskellLanguage), new JSONSchemaFixture(languages.ElixirLanguage), + new JSONSchemaFixture(languages.GleamLanguage), new CommentInjectionSchemaFixture(languages.TypeScriptLanguage), new CommentInjectionSchemaFixture(languages.ObjectiveCLanguage), new CommentInjectionSchemaFixture(languages.TypeScriptZodLanguage, [ diff --git a/test/fixtures/gleam/src/quicktype.gleam b/test/fixtures/gleam/src/quicktype.gleam new file mode 100644 index 0000000000..8df37e7d8e --- /dev/null +++ b/test/fixtures/gleam/src/quicktype.gleam @@ -0,0 +1,20 @@ +// Placeholder module, overwritten by quicktype-generated output during the +// fixture run. It exists only so `setup` can compile the project's +// dependencies ahead of time; that keeps each per-sample `gleam build` light +// enough to avoid the gleam compiler crashing under parallel dependency +// compilation. + +import gleam/dynamic/decode +import gleam/json + +pub type TopLevel { + TopLevel +} + +pub fn top_level_decoder() -> decode.Decoder(TopLevel) { + decode.success(TopLevel) +} + +pub fn top_level_to_json(_value: TopLevel) -> json.Json { + json.null() +} diff --git a/test/languages.ts b/test/languages.ts index 8f534c31d2..1b3a99c3ee 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -837,6 +837,58 @@ export const ElmLanguage: Language = { sourceFiles: ["src/language/Elm/index.ts"], }; +export const GleamLanguage: Language = { + name: "gleam", + base: "test/fixtures/gleam", + // The gleam compiler (via its BEAM erlang backend) segfaults + // intermittently when many fixture workers compile in parallel; a single + // build never crashes. Two mitigations, both required: + // 1. Build — not just download — the dependencies up front against the + // committed placeholder src/quicktype.gleam, so each per-sample build + // recompiles only the generated module, not the shared deps. + // 2. Serialize the per-sample `gleam build` across workers with a shared + // lock, so no two module compilations ever run at once. + setupCommand: "gleam build", + compileCommand: + "flock /tmp/quicktype-gleam-build.lock gleam build && gleam format --check src/", + runCommand(sample: string) { + return `gleam run -- "${sample}"`; + }, + diffViaSchema: true, + // These inputs name types/fields/enum cases differently on the JSON and + // JSON-Schema paths (e.g. `link_type` vs `link_link_type`, enum case + // `Leaddatum` vs `Purple`); the generated Gleam is correct either way but + // not byte-identical. Every diffViaSchema language skips this class. + skipDiffViaSchema: ["bug427.json", "github-events.json", "recursive.json"], + allowMissingNull: true, + features: ["union", "integer"], + output: "src/quicktype.gleam", + topLevel: "TopLevel", + skipJSON: [ + // The generated module is enormous and the gleam compiler segfaults + // while checking it (reproduces with `gleam check`, even with an + // unlimited stack). + "keywords.json", + "nst-test-suite.json", + // The gleam Erlang backend errors on a JSON key containing a literal + // tab and emoji ("Strings which contain Emoji..."), emitting invalid + // Erlang for an otherwise valid Gleam module. + "blns-object.json", + // The gleam compiler crashes on this generated module a few percent of + // the time even for an isolated `gleam build` (other large modules do + // not); the flake is specific to its structure. + "nbl-stats.json", + ], + skipMiscJSON: false, + skipSchema: [ + // ~17k-line generated module; the gleam compiler segfaults checking it. + "keyword-unions.schema", + ], + rendererOptions: {}, + quickTestRendererOptions: [], + sourceFiles: ["src/language/Gleam/index.ts"], +}; + export const SwiftLanguage: Language = { name: "swift", base: "test/fixtures/swift", From 69447ceedbb7bd8c06815f15100969183cc8a3eb Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 17 Sep 2026 00:17:50 +0000 Subject: [PATCH 07/19] feat(ci): run gleam fixtures Add a gleam,schema-gleam row to the fixture matrix in test-pr.yaml and an "Install Gleam" step guarded by contains(matrix.fixture, 'gleam'), using erlef/setup-beam pinned to OTP 27 (required by gleam_json's OTP json backend) and gleam 1.14.0, mirroring the Elixir install step. --- .github/workflows/test-pr.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 9a0dc301a4..224b2954d5 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -58,6 +58,7 @@ jobs: - scala3-upickle,schema-scala3-upickle - elixir,schema-elixir,graphql-elixir - elm,schema-elm + - gleam,schema-gleam - comment-injection-treesitter,comment-injection-typescript,comment-injection-typescript-zod,comment-injection-typescript-effect-schema # Not yet started @@ -205,6 +206,15 @@ jobs: elixir-version: "1.15.7" otp-version: "26.0" + - name: Install Gleam + if: ${{ contains(matrix.fixture, 'gleam') }} + uses: erlef/setup-beam@v1 + with: + # gleam_json delegates to OTP's built-in json module, which + # requires OTP 27 or newer. + otp-version: "27.0" + gleam-version: "1.14.0" + - run: QUICKTEST=true FIXTURE=${{ matrix.fixture }} npm run test:fixtures env: CPUs: ${{ contains(matrix.fixture, 'scala3') && '2' || '0' }} From dbe50af01d5e106998a5c595785af62c4a019dec Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 17 Sep 2026 00:18:34 +0000 Subject: [PATCH 08/19] docs: list Gleam as a supported language Add Gleam to the target-language table in README.md and to the supports line in packages/quicktype-vscode/README.md. --- README.md | 4 ++-- packages/quicktype-vscode/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fe089d85a5..3bc42106fc 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ | [Java](https://app.quicktype.io/#l=java) | [Scala](https://app.quicktype.io/#l=scala3) | [TypeScript](https://app.quicktype.io/#l=ts) | [Swift](https://app.quicktype.io/#l=swift) | [Objective-C](https://app.quicktype.io/#l=objc) | [Elm](https://app.quicktype.io/#l=elm) | | ---------------------------------------- | ------------------------------------------- | -------------------------------------------- | ------------------------------------------ | ----------------------------------------------- | -------------------------------------- | -| [JSON Schema](https://app.quicktype.io/#l=schema) | [Pike](https://app.quicktype.io/#l=pike) | [Prop-Types](https://app.quicktype.io/#l=javascript-prop-types) | [Haskell](https://app.quicktype.io/#l=haskell) | [PHP](https://app.quicktype.io/#l=php) | -| ------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------- | +| [JSON Schema](https://app.quicktype.io/#l=schema) | [Pike](https://app.quicktype.io/#l=pike) | [Prop-Types](https://app.quicktype.io/#l=javascript-prop-types) | [Haskell](https://app.quicktype.io/#l=haskell) | [PHP](https://app.quicktype.io/#l=php) | [Gleam](https://app.quicktype.io/#l=gleam) | +| ------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------- | ------------------------------------------ | _Missing your favorite language? Please implement it!_ diff --git a/packages/quicktype-vscode/README.md b/packages/quicktype-vscode/README.md index 652fb05fa6..dab29f91a1 100644 --- a/packages/quicktype-vscode/README.md +++ b/packages/quicktype-vscode/README.md @@ -1,4 +1,4 @@ -**Supports** `C (cJSON)`, `C#`, `C++`, `Crystal`, `Dart`, `Elm`, `Flow`, `Go`, `Haskell`, `JSON Schema`, `Java`, `JavaScript`, `JavaScript PropTypes`, `Kotlin`, `Objective-C`, `PHP`, `Pike`, `Python`, `Ruby`, `Rust`, `Scala3`, `Smithy`, `Swift`, `TypeScript`, `TypeScript Effect Schema` and `TypeScript Zod` +**Supports** `C (cJSON)`, `C#`, `C++`, `Crystal`, `Dart`, `Elm`, `Flow`, `Gleam`, `Go`, `Haskell`, `JSON Schema`, `Java`, `JavaScript`, `JavaScript PropTypes`, `Kotlin`, `Objective-C`, `PHP`, `Pike`, `Python`, `Ruby`, `Rust`, `Scala3`, `Smithy`, `Swift`, `TypeScript`, `TypeScript Effect Schema` and `TypeScript Zod` - Interactively generate types and (de-)serialization code from JSON, JSON Schema, and TypeScript - Paste JSON/JSON Schema/TypeScript as code From 092a23c42588ddb550bdd04efc376f140e1acea9 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 19 Sep 2026 13:05:40 -0400 Subject: [PATCH 09/19] fix(gleam): silence empty-record warning A record with no properties gets an encoder whose argument is never read, so Gleam warned about an unused `value`. The encoder now names that parameter `_value` when the record has no required or optional fields. --- .../quicktype-core/src/language/Gleam/GleamRenderer.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index daf3f6bded..b5b53ba3e9 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -567,17 +567,18 @@ export class GleamRenderer extends ConvenienceRenderer { fnName: string, paramType: string, returnType: string, + paramName = "value", ): void { // `gleam format` measures the signature width without the trailing // ` {` block opener. - const signature = `pub fn ${fnName}(value: ${paramType}) -> ${returnType}`; + const signature = `pub fn ${fnName}(${paramName}: ${paramType}) -> ${returnType}`; if (signature.length <= MAX_WIDTH) { this.line(0, `${signature} {`); return; } this.line(0, `pub fn ${fnName}(`); - this.line(2, `value: ${paramType},`); + this.line(2, `${paramName}: ${paramType},`); this.line(0, `) -> ${returnType} {`); } @@ -608,7 +609,10 @@ export class GleamRenderer extends ConvenienceRenderer { } }); - this.emitFnHeader(encoder, typeName, "json.Json"); + // A property-less record's encoder ignores its argument. + const paramName = + required.length === 0 && optional.length === 0 ? "_value" : "value"; + this.emitFnHeader(encoder, typeName, "json.Json", paramName); if (optional.length === 0) { this.emitDoc(2, call("json.object", [list(required)])); } else { From 283801a188e0395506c349cfda3a60fc312e2772 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 19 Sep 2026 13:05:54 -0400 Subject: [PATCH 10/19] test(gleam): retry crashed fixture builds The Gleam compiler segfaults at a low rate on large generated modules under parallel fixture load. The compile command now retries `gleam build` up to five times inside the existing lock, since a fresh compile of the same module almost always succeeds. --- test/languages.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/languages.ts b/test/languages.ts index 1b3a99c3ee..5700fd40bf 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -840,17 +840,19 @@ export const ElmLanguage: Language = { export const GleamLanguage: Language = { name: "gleam", base: "test/fixtures/gleam", - // The gleam compiler (via its BEAM erlang backend) segfaults - // intermittently when many fixture workers compile in parallel; a single - // build never crashes. Two mitigations, both required: + // The gleam compiler (via its BEAM erlang backend) segfaults at a low rate + // when compiling large generated modules, especially under the fixture's + // parallel load. Three mitigations: // 1. Build — not just download — the dependencies up front against the // committed placeholder src/quicktype.gleam, so each per-sample build // recompiles only the generated module, not the shared deps. // 2. Serialize the per-sample `gleam build` across workers with a shared // lock, so no two module compilations ever run at once. + // 3. Retry a crashed build a few times; a fresh compile of the same + // module almost always succeeds. setupCommand: "gleam build", compileCommand: - "flock /tmp/quicktype-gleam-build.lock gleam build && gleam format --check src/", + "flock /tmp/quicktype-gleam-build.lock sh -c 'for i in 1 2 3 4 5; do gleam build && exit 0; done; exit 1' && gleam format --check src/", runCommand(sample: string) { return `gleam run -- "${sample}"`; }, From 7e0362614295bdd2cd21bccbe44859c0675a6896 Mon Sep 17 00:00:00 2001 From: Brian Date: Fri, 18 Sep 2026 16:18:33 -0400 Subject: [PATCH 11/19] fix(gleam): guard only cycle breakers with decode.recursive Every emitted decoder used to open with `decode.recursive`, which re-runs the body on each decode. Now only classes and unions chosen as cycle breakers get the guard, and the JsonValue decoder keeps it because it is directly self-recursive. Enums and containers never need one. --- .../src/language/Gleam/GleamRenderer.ts | 35 ++++- test/unit/gleam-decoders.test.ts | 134 +++++++++++++++--- 2 files changed, 143 insertions(+), 26 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index b5b53ba3e9..4d75154399 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -115,6 +115,19 @@ export class GleamRenderer extends ConvenienceRenderer { return { names: [], includeGlobalForbidden: true }; } + // Unlike `Vec` in Rust, `decode.list(x)` evaluates `x` eagerly, so a + // container gives no representational indirection and breaks nothing. + protected isImplicitCycleBreaker(_t: Type): boolean { + return false; + } + + // Only classes and unions get an emitted decoder function that can carry a + // `decode.recursive` guard; picking any other type (e.g. an array or map) + // would leave a cycle with nowhere to put the guard. + protected canBreakCycles(t: Type): boolean { + return t instanceof ClassType || t instanceof UnionType; + } + // Prefix union constructors with the union name so they read well and stay // globally unique (e.g. `FooString`, `FooInteger`). protected proposeUnionMemberName( @@ -750,10 +763,12 @@ export class GleamRenderer extends ConvenienceRenderer { private emitClassDecoder(c: ClassType, className: Name): void { const typeName = this.nameToString(className); this.emitDecoderHeader(this.decoderName(c), typeName); - // Rule 1: every decoder opens with `decode.recursive`. Omitting it on a - // decoder that reaches itself compiles clean, then hangs forever at - // construction time. Applying it uniformly is harmless and safe. - this.line(2, "use <- decode.recursive"); + // Rule 1: a decoder that reaches itself and omits `decode.recursive` + // compiles clean, then hangs forever at construction time. The guard + // re-runs the body on every decode, so emit it only where needed. + if (this.isCycleBreakerType(c)) { + this.line(2, "use <- decode.recursive"); + } const fields: Doc[] = []; this.forEachClassProperty(c, "none", (name, jsonName, p) => { @@ -820,7 +835,6 @@ export class GleamRenderer extends ConvenienceRenderer { private emitEnumDecoder(e: EnumType, enumName: Name): void { const typeName = this.nameToString(enumName); this.emitDecoderHeader(this.decoderName(e), typeName); - this.line(2, "use <- decode.recursive"); this.emitUse( 2, "variant", @@ -857,7 +871,9 @@ export class GleamRenderer extends ConvenienceRenderer { const typeName = this.nameToString(unionName); const [, nonNulls] = removeNullFromUnion(u); this.emitDecoderHeader(this.decoderName(u), typeName); - this.line(2, "use <- decode.recursive"); + if (this.isCycleBreakerType(u)) { + this.line(2, "use <- decode.recursive"); + } const members: Doc[] = []; let anyMember: Doc | undefined; @@ -900,13 +916,18 @@ export class GleamRenderer extends ConvenienceRenderer { this.nameToString(functions.decoder), this.nameToString(name), ); - this.line(2, "use <- decode.recursive"); + if (this.isCycleBreakerType(t)) { + this.line(2, "use <- decode.recursive"); + } this.emitDoc(2, this.decoderDoc(t)); this.line(0, "}"); } private emitJsonValueDecoder(): void { this.emitDecoderHeader("json_value_decoder", "JsonValue"); + // Always guarded: the JsonArray and JsonObject variants below call + // `json_value_decoder()` in eager argument position, so this decoder + // is literally self-recursive. this.line(2, "use <- decode.recursive"); this.emitOneOf([ call("decode.map", [text("decode.bool"), text("JsonBool")]), diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts index 5e1f711229..131deaab25 100644 --- a/test/unit/gleam-decoders.test.ts +++ b/test/unit/gleam-decoders.test.ts @@ -28,33 +28,129 @@ async function gleamFromSchema(schema: string): Promise { // Rule 1 (highest severity): a decoder that reaches itself and omits // `decode.recursive` compiles clean, then hangs forever at construction time. // Fixture tests cannot catch this — the job simply times out — so assert the -// guard on every generated decoder here. -test("every Gleam decoder opens with `use <- decode.recursive`", async () => { - const output = await gleamFromJSON("Tree", [ - JSON.stringify({ - value: 1, - label: "root", - children: [{ value: 2, label: "leaf", children: [] }], - }), - ]); - +// guarded set exactly here: a missing guard hangs, while a superfluous guard +// rebuilds the decoder once per decoded value. +function decoderGuards(output: string): { + decoders: string[]; + guarded: string[]; +} { const lines = output.split("\n"); - const headerIndices = lines - .map((line, index) => ({ line, index })) - .filter(({ line }) => /^pub fn \w+_decoder\(/.test(line)) - .map(({ index }) => index); + const decoders: string[] = []; + const guarded: string[] = []; + lines.forEach((line, index) => { + const match = /^pub fn (\w+_decoder)\(/.exec(line); + if (match === null) { + return; + } - expect(headerIndices.length).toBeGreaterThan(0); - for (const headerIndex of headerIndices) { + decoders.push(match[1]); // A header may span multiple lines when its return type is long; the // body starts on the line after the one ending in `{`. - let bodyIndex = headerIndex; + let bodyIndex = index; while (!lines[bodyIndex].endsWith("{")) { bodyIndex += 1; } - expect(lines[bodyIndex + 1].trim()).toBe("use <- decode.recursive"); - } + if (lines[bodyIndex + 1].trim() === "use <- decode.recursive") { + guarded.push(match[1]); + } + }); + return { decoders, guarded }; +} + +test("a self-recursive class decoder is guarded", async () => { + // The recursive field comes first, so without the guard construction + // recurses before any other combinator can run. + const output = await gleamFromJSON("Tree", [ + JSON.stringify({ + children: [{ children: [], value: 2 }], + value: 1, + }), + ]); + + const { decoders, guarded } = decoderGuards(output); + expect(decoders).toEqual(["tree_decoder"]); + expect(guarded).toEqual(["tree_decoder"]); +}); + +test("mutual recursion through a union is guarded", async () => { + const output = await gleamFromSchema( + JSON.stringify({ + $ref: "#/definitions/Node", + definitions: { + Node: { + type: "object", + additionalProperties: false, + properties: { value: { $ref: "#/definitions/Value" } }, + required: ["value"], + }, + Value: { + oneOf: [{ $ref: "#/definitions/Node" }, { type: "string" }], + }, + }, + }), + ); + + const { decoders, guarded } = decoderGuards(output); + expect(decoders).toEqual(["top_level_decoder", "value_decoder"]); + // One lazy point per cycle suffices: with the class guarded, calling + // `value_decoder()` constructs `top_level_decoder()` in O(1), so the + // union decoder needs no guard of its own. + expect(guarded).toEqual(["top_level_decoder"]); +}); + +test("`json_value_decoder` is always guarded", async () => { + const output = await gleamFromSchema( + JSON.stringify({ type: ["string", "integer", "array", "object"] }), + ); + + const { decoders, guarded } = decoderGuards(output); + expect(decoders).toEqual(["json_value_decoder", "top_level_decoder"]); + expect(guarded).toEqual(["json_value_decoder"]); +}); + +test("a flat object emits no `decode.recursive` at all", async () => { + const output = await gleamFromJSON("Flat", [ + JSON.stringify({ a: 1, b: "x" }), + ]); + + const { decoders, guarded } = decoderGuards(output); + expect(decoders).toEqual(["flat_decoder"]); + expect(guarded).toEqual([]); + expect(output).not.toContain("decode.recursive"); +}); + +test("an enum decoder is unguarded", async () => { + // The body is a string `case` with literal success/failure arms and no + // child decoder, so it cannot re-enter itself. + const output = await gleamFromSchema( + JSON.stringify({ type: "string", enum: ["hot", "cold"] }), + ); + + const { decoders, guarded } = decoderGuards(output); + expect(decoders).toEqual(["top_level_decoder"]); + expect(guarded).toEqual([]); +}); + +test("a top-level array alias decoder is unguarded", async () => { + const output = await gleamFromSchema( + JSON.stringify({ + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { a: { type: "integer" } }, + required: ["a"], + }, + }), + ); + + const { decoders, guarded } = decoderGuards(output); + expect(decoders).toEqual([ + "top_level_element_decoder", + "top_level_decoder", + ]); + expect(guarded).toEqual([]); }); // Rule 3: `JsonValue` matches every input, so the variant that decodes to it From f0d6029ea7aa71b4c5e5111d6504ba66f6a5fdfc Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 19 Sep 2026 13:06:56 -0400 Subject: [PATCH 12/19] feat: let languages keep integer and double unions in inference Adds an opt-in `infersUnionsWithBothNumberTypes` flag on `TargetLanguage`. When set together with `supportsUnionsWithBothNumberTypes`, JSON inference keeps `[1, 1.5]` as `integer | double` instead of widening to `double`. Off by default; no language changes behavior yet. --- packages/quicktype-core/src/Run.ts | 14 ++++++++++ packages/quicktype-core/src/TargetLanguage.ts | 18 +++++++++++++ .../quicktype-core/src/input/Inference.ts | 5 ++-- packages/quicktype-core/src/input/Inputs.ts | 3 ++- .../inputs/json/priority/int-float-union.json | 26 +++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 test/inputs/json/priority/int-float-union.json diff --git a/packages/quicktype-core/src/Run.ts b/packages/quicktype-core/src/Run.ts index 898640aff5..3a0eb10e86 100644 --- a/packages/quicktype-core/src/Run.ts +++ b/packages/quicktype-core/src/Run.ts @@ -148,6 +148,12 @@ const defaultOptions: NonInferenceOptions = { }; export interface RunContext { + /** + * Whether JSON inference merges `integer` and `double` into `double` + * when samples mix them. See + * `TargetLanguage.infersUnionsWithBothNumberTypes`. + */ + conflateNumbersInInference: boolean; debugPrintReconstitution: boolean; debugPrintSchemaResolving: boolean; debugPrintTransformations: boolean; @@ -190,6 +196,14 @@ class Run implements RunContext { return mapping; } + public get conflateNumbersInInference(): boolean { + const targetLanguage = getTargetLanguage(this._options.lang); + return !( + targetLanguage.supportsUnionsWithBothNumberTypes && + targetLanguage.infersUnionsWithBothNumberTypes + ); + } + public get debugPrintReconstitution(): boolean { return this._options.debugPrintReconstitution === true; } diff --git a/packages/quicktype-core/src/TargetLanguage.ts b/packages/quicktype-core/src/TargetLanguage.ts index f0617b7390..1a4ad310ce 100644 --- a/packages/quicktype-core/src/TargetLanguage.ts +++ b/packages/quicktype-core/src/TargetLanguage.ts @@ -111,6 +111,24 @@ export abstract class TargetLanguage< return false; } + /** + * Whether inference from JSON samples keeps `integer` and `double` as + * separate union members when one value position mixes them, so that + * `[1, 1.5]` infers `integer | double` instead of `double`. + * + * Off by default: JSON does not distinguish `1` from `1.0`, and most + * languages have a single numeric type that fits both. A language whose + * integer and floating-point types are disjoint at runtime (Gleam's + * `Int` and `Float`) opts in so a whole number is not widened to a float. + * + * Only meaningful together with `supportsUnionsWithBothNumberTypes`; + * without it, later rewrites conflate the inferred union back into + * `double`. + */ + public get infersUnionsWithBothNumberTypes(): boolean { + return false; + } + public get supportsFullObjectType(): boolean { return false; } diff --git a/packages/quicktype-core/src/input/Inference.ts b/packages/quicktype-core/src/input/Inference.ts index 4efca3c4f2..65d4a3caf1 100644 --- a/packages/quicktype-core/src/input/Inference.ts +++ b/packages/quicktype-core/src/input/Inference.ts @@ -120,6 +120,7 @@ export class TypeInference { private readonly _typeBuilder: TypeBuilder, private readonly _inferMaps: boolean, private readonly _inferEnums: boolean, + private readonly _conflateNumbers: boolean, ) {} private addValuesToAccumulator( @@ -325,7 +326,7 @@ export class TypeInference { const accumulator = new UnionAccumulator< NestedValueArray, NestedValueArray - >(true); + >(this._conflateNumbers); this.addValuesToAccumulator(valueArray, accumulator); return accumulator; } @@ -398,7 +399,7 @@ export class TypeInference { const accumulator = new UnionAccumulator< NestedValueArray, NestedValueArray - >(true); + >(this._conflateNumbers); for (const key of propertyNames) { this.addValuesToAccumulator(propertyValues[key], accumulator); } diff --git a/packages/quicktype-core/src/input/Inputs.ts b/packages/quicktype-core/src/input/Inputs.ts index 65c31448de..6fa372e9b0 100644 --- a/packages/quicktype-core/src/input/Inputs.ts +++ b/packages/quicktype-core/src/input/Inputs.ts @@ -172,7 +172,7 @@ export class JSONInput implements Input> { } public addTypesSync( - _ctx: RunContext, + ctx: RunContext, typeBuilder: TypeBuilder, inferMaps: boolean, inferEnums: boolean, @@ -183,6 +183,7 @@ export class JSONInput implements Input> { typeBuilder, inferMaps, inferEnums, + ctx.conflateNumbersInInference, ); for (const [name, { samples, description }] of this._topLevels) { diff --git a/test/inputs/json/priority/int-float-union.json b/test/inputs/json/priority/int-float-union.json new file mode 100644 index 0000000000..bc15df22c9 --- /dev/null +++ b/test/inputs/json/priority/int-float-union.json @@ -0,0 +1,26 @@ +[ + { + "whole_or_fraction": 123, + "number_or_text": 123, + "maybe_number": 1, + "numbers": [1, 2.5], + "fraction": 1.5, + "whole": 1 + }, + { + "whole_or_fraction": 12.3, + "number_or_text": "Hello Dolly", + "maybe_number": 2.5, + "numbers": [3], + "fraction": 2.5, + "whole": 2 + }, + { + "whole_or_fraction": 7, + "number_or_text": 12.3, + "maybe_number": null, + "numbers": [], + "fraction": 0.5, + "whole": 3 + } +] From de39ee78a7ceeb7404d3bd040e581755f574d0a8 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 19 Sep 2026 13:06:56 -0400 Subject: [PATCH 13/19] feat(gleam): infer integer and double as separate types Gleam opts into the new flag so mixed samples infer `Int | Float` rather than re-encoding `123` as `123.0`. Number unions try `decode.int` before strict `decode.float`, `gleam/int` is imported only when the lenient number decoder is used, and the fixture round-trips schema-path code instead of diffing it. --- .../src/language/Gleam/GleamRenderer.ts | 76 +++++++++++++------ .../src/language/Gleam/language.ts | 7 ++ test/languages.ts | 13 ++-- test/unit/gleam-decoders.test.ts | 68 +++++++++++++++++ 4 files changed, 135 insertions(+), 29 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index 4d75154399..cb20510603 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -1,3 +1,5 @@ +import { iterableSome } from "collection-utils"; + import { ConvenienceRenderer, type ForbiddenWordsInfo, @@ -12,6 +14,7 @@ import { ClassType, type EnumType, type Type, + type TypeKind, UnionType, } from "../../Type/index.js"; @@ -453,8 +456,9 @@ export class GleamRenderer extends ConvenienceRenderer { (_integerType) => text("decode.int"), // On Erlang `decode.float` rejects an integral JSON number, so a // `number` field must accept an int and widen it. - (_doubleType) => - call("decode.one_of", [ + (_doubleType) => { + this._needsInt = true; + return call("decode.one_of", [ text("decode.float"), list([ call("decode.map", [ @@ -462,7 +466,8 @@ export class GleamRenderer extends ConvenienceRenderer { text("int.to_float"), ]), ]), - ]), + ]); + }, (_stringType) => text("decode.string"), (arrayType) => call("decode.list", [this.decoderDoc(arrayType.items)]), @@ -875,24 +880,46 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(2, "use <- decode.recursive"); } - const members: Doc[] = []; - let anyMember: Doc | undefined; + // A union of `Int` and `Float` decodes each with its strict decoder, + // and tries `Int` first: the lenient `number` decoder would claim + // every whole number for the `Float` variant, and on the JavaScript + // target `decode.float` accepts integral numbers too. + const isNumberUnion = + iterableSome(nonNulls, (t) => t.kind === "integer") && + iterableSome(nonNulls, (t) => t.kind === "double"); + + const entries: Array<{ kind: TypeKind; variant: Doc }> = []; this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { - const variant = call("decode.map", [ - this.decoderDoc(t), - text(this.nameToString(memberName)), - ]); - // Rule 3: `JsonValue` matches every input, so its variant must come - // last or it would shadow the others. - if (t.kind === "any") { - anyMember = variant; - } else { - members.push(variant); - } + const decoder = + isNumberUnion && t.kind === "double" + ? text("decode.float") + : this.decoderDoc(t); + entries.push({ + kind: t.kind, + variant: call("decode.map", [ + decoder, + text(this.nameToString(memberName)), + ]), + }); }); - if (anyMember !== undefined) { - members.push(anyMember); + if (isNumberUnion) { + const integerIndex = entries.findIndex((e) => e.kind === "integer"); + const doubleIndex = entries.findIndex((e) => e.kind === "double"); + if (integerIndex > doubleIndex) { + const [integerEntry] = entries.splice(integerIndex, 1); + entries.splice(doubleIndex, 0, integerEntry); + } + } + + // Rule 3: `JsonValue` matches every input, so its variant must come + // last or it would shadow the others. + const members = entries + .filter((e) => e.kind !== "any") + .map((e) => e.variant); + const anyEntry = entries.find((e) => e.kind === "any"); + if (anyEntry !== undefined) { + members.push(anyEntry.variant); } this.emitOneOf(members); @@ -978,11 +1005,6 @@ export class GleamRenderer extends ConvenienceRenderer { if (t.kind === "map") { this._needsDict = true; } - - // The `number` decoder widens an int with `int.to_float`. - if (t.kind === "double") { - this._needsInt = true; - } } // The `JsonObject` variant of `JsonValue` carries a `Dict`. @@ -994,6 +1016,10 @@ export class GleamRenderer extends ConvenienceRenderer { protected emitSourceStructure(): void { this.computeNeeds(); + // The body decides whether `gleam/int` is imported (only the lenient + // `number` decoder uses it), so gather it before writing the header. + const body = this.gatherSource(() => this.emitBody()); + this.line(0, "// Generated by quicktype"); this.line(0, "//"); this.line( @@ -1021,6 +1047,10 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "import gleam/option"); } + this.emitGatheredSource(body); + } + + private emitBody(): void { if (this._needsJsonValue) { this.line(0); this.emitJsonValueType(); diff --git a/packages/quicktype-core/src/language/Gleam/language.ts b/packages/quicktype-core/src/language/Gleam/language.ts index 82c4d08b89..f5108b3caa 100644 --- a/packages/quicktype-core/src/language/Gleam/language.ts +++ b/packages/quicktype-core/src/language/Gleam/language.ts @@ -33,6 +33,13 @@ export class GleamTargetLanguage extends TargetLanguage< return true; } + // `Int` and `Float` are distinct types, so JSON samples that mix + // `123` and `12.3` infer an `Int | Float` union rather than widening + // every whole number to a `Float` that re-encodes as `123.0`. + public get infersUnionsWithBothNumberTypes(): boolean { + return true; + } + protected makeRenderer(renderContext: RenderContext): GleamRenderer { return new GleamRenderer(this, renderContext); } diff --git a/test/languages.ts b/test/languages.ts index 5700fd40bf..6c38a9c7cc 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -856,12 +856,13 @@ export const GleamLanguage: Language = { runCommand(sample: string) { return `gleam run -- "${sample}"`; }, - diffViaSchema: true, - // These inputs name types/fields/enum cases differently on the JSON and - // JSON-Schema paths (e.g. `link_type` vs `link_link_type`, enum case - // `Leaddatum` vs `Purple`); the generated Gleam is correct either way but - // not byte-identical. Every diffViaSchema language skips this class. - skipDiffViaSchema: ["bug427.json", "github-events.json", "recursive.json"], + // The JSON path infers an `Int | Float` union wherever samples mix whole + // and fractional numbers, but JSON Schema can only say `number`, so code + // generated via the schema collapses those unions to `Float` and can never + // be byte-identical. Compile and round-trip the schema-path code instead. + diffViaSchema: false, + roundtripViaSchema: true, + skipDiffViaSchema: [], allowMissingNull: true, features: ["union", "integer"], output: "src/quicktype.gleam", diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts index 131deaab25..e64ca47878 100644 --- a/test/unit/gleam-decoders.test.ts +++ b/test/unit/gleam-decoders.test.ts @@ -177,3 +177,71 @@ test("the universal `JsonValue` catch-all is emitted last", async () => { // closing delimiters of the `one_of`. expect(decoder.slice(catchAll)).not.toContain("decode.map"); }); + +// Number unions. `Int` and `Float` are distinct Gleam types, so JSON samples +// that mix whole and fractional numbers infer an `Int | Float` union instead +// of widening every whole number to a `Float`. Fixture round trips cannot pin +// this: the comparison parses JSON, so `123` and `123.0` compare equal. +test("JSON samples mixing whole and fractional numbers infer an Int | Float union", async () => { + const output = await gleamFromJSON("Bar", [ + JSON.stringify([{ foo: 123 }, { foo: 12.3 }]), + ]); + + expect(output).toContain( + ["pub type Foo {", " FooDouble(Float)", " FooInteger(Int)", "}"].join( + "\n", + ), + ); +}); + +test("a number union alongside another type keeps both number variants", async () => { + const output = await gleamFromJSON("Bar", [ + JSON.stringify([{ foo: 123 }, { foo: "Hello Dolly" }, { foo: 12.3 }]), + ]); + + expect(output).toContain( + [ + "pub type Foo {", + " FooDouble(Float)", + " FooInteger(Int)", + " FooString(String)", + "}", + ].join("\n"), + ); +}); + +// The lenient `number` decoder accepts a whole number, and on the JavaScript +// target so does `decode.float`, so the `Int` variant must be tried first and +// the `Float` variant decoded strictly. Ordered the other way, `123` decodes +// as `FooDouble(123.0)` and `FooInteger` is unreachable. +test("an Int | Float union tries Int first and decodes Float strictly", async () => { + const output = await gleamFromJSON("Bar", [ + JSON.stringify([{ foo: 123 }, { foo: "Hello Dolly" }, { foo: 12.3 }]), + ]); + + expect(output).toContain( + [ + "pub fn foo_decoder() -> decode.Decoder(Foo) {", + " decode.one_of(decode.map(decode.int, FooInteger), [", + " decode.map(decode.float, FooDouble),", + " decode.map(decode.string, FooString),", + " ])", + "}", + ].join("\n"), + ); + // Nothing widens an int here, so `gleam/int` is not imported. + expect(output).not.toContain("int.to_float"); + expect(output).not.toContain("import gleam/int"); +}); + +test("a plain Float field keeps the lenient number decoder and its import", async () => { + const output = await gleamFromJSON("Bar", [ + JSON.stringify([{ foo: 1.5 }, { foo: 2.5 }]), + ]); + + expect(output).toContain("foo: Float"); + expect(output).toContain( + "decode.one_of(decode.float, [decode.map(decode.int, int.to_float)])", + ); + expect(output).toContain("import gleam/int"); +}); From 3982601faa06e516cf81c1b360b7af358d3d5fe7 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 19 Sep 2026 18:01:29 -0400 Subject: [PATCH 14/19] fix(gleam): correct the blns-object skip reason Not a tab-and-emoji key: a 280-char key makes a 270-char Erlang variable, past the 255-char atom cap. `gleam check` passes, so the fault is the Erlang backend. A 270-char synthetic key reproduces it. https://www.erlang.org/doc/system/system_limits.html --- test/languages.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/languages.ts b/test/languages.ts index 6c38a9c7cc..3c4793034b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -873,9 +873,12 @@ export const GleamLanguage: Language = { // unlimited stack). "keywords.json", "nst-test-suite.json", - // The gleam Erlang backend errors on a JSON key containing a literal - // tab and emoji ("Strings which contain Emoji..."), emitting invalid - // Erlang for an otherwise valid Gleam module. + // A 280-character JSON key becomes a 270-character Gleam identifier, + // which the Erlang backend emits as an Erlang variable of the same + // length. Erlang's scanner interns variable names as atoms, capped at + // 255 characters, so erlc rejects it with "illegal var". `gleam check` + // passes; only `gleam build` fails. Reproduces with any key over ~255 + // characters, independent of its contents. "blns-object.json", // The gleam compiler crashes on this generated module a few percent of // the time even for an isolated `gleam build` (other large modules do From 4aeaa8645c9de3db07e75a2df69c5968e2296051 Mon Sep 17 00:00:00 2001 From: Brian Date: Sat, 19 Sep 2026 18:01:32 -0400 Subject: [PATCH 15/19] docs(gleam): drop comments that restate the code Deleted any superfluous code comment that re-described something obvious in the code itself. Comments recording external constraints stay. --- .../quicktype-core/src/language/Gleam/GleamRenderer.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index cb20510603..346373112d 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -340,7 +340,6 @@ export class GleamRenderer extends ConvenienceRenderer { ); } - // A `fn(a) -> json.Json` value that encodes a `t`. private encoderFn(t: Type): Doc { return matchType( t, @@ -397,7 +396,6 @@ export class GleamRenderer extends ConvenienceRenderer { ); } - // Whether a type admits a JSON null (a `T | null` union). private isNullableType(t: Type): boolean { if (t instanceof UnionType) { if (nullableFromUnion(t) !== null) { @@ -411,13 +409,11 @@ export class GleamRenderer extends ConvenienceRenderer { return false; } - // Whether a class property is represented as `option.Option(_)` in Gleam. // Both absent (optional) and null (nullable) properties map to `Option`. private isGleamOptional(t: Type, isOptional: boolean): boolean { return isOptional || this.isNullableType(t); } - // The non-null Gleam type wrapped inside a property's `Option(_)`. private innerTypeDoc(t: Type): Doc { if (t instanceof UnionType) { const nullable = nullableFromUnion(t); @@ -446,7 +442,6 @@ export class GleamRenderer extends ConvenienceRenderer { return this.encodeDoc(t, value); } - // A `decode.Decoder(T)` value for `t`. private decoderDoc(t: Type): Doc { return matchType( t, @@ -496,8 +491,6 @@ export class GleamRenderer extends ConvenienceRenderer { ); } - // The non-null decoder for a property's inner type (the value inside its - // `Option`). private decoderInner(t: Type): Doc { if (t instanceof UnionType) { const nullable = nullableFromUnion(t); From 33eb32144662c7177719eb4a0699e8f1bb4a681f Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 21 Sep 2026 09:19:54 -0400 Subject: [PATCH 16/19] revert(gleam): drop the Int|Float union inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The union is unidiomatic Gleam. The lenient Float decoder test stays — it predates the union. --- packages/quicktype-core/src/Run.ts | 14 ---- packages/quicktype-core/src/TargetLanguage.ts | 18 ----- .../quicktype-core/src/input/Inference.ts | 5 +- packages/quicktype-core/src/input/Inputs.ts | 3 +- .../src/language/Gleam/GleamRenderer.ts | 76 ++++++------------- .../src/language/Gleam/language.ts | 7 -- .../inputs/json/priority/int-float-union.json | 26 ------- test/languages.ts | 13 ++-- test/unit/gleam-decoders.test.ts | 60 +-------------- 9 files changed, 36 insertions(+), 186 deletions(-) delete mode 100644 test/inputs/json/priority/int-float-union.json diff --git a/packages/quicktype-core/src/Run.ts b/packages/quicktype-core/src/Run.ts index 3a0eb10e86..898640aff5 100644 --- a/packages/quicktype-core/src/Run.ts +++ b/packages/quicktype-core/src/Run.ts @@ -148,12 +148,6 @@ const defaultOptions: NonInferenceOptions = { }; export interface RunContext { - /** - * Whether JSON inference merges `integer` and `double` into `double` - * when samples mix them. See - * `TargetLanguage.infersUnionsWithBothNumberTypes`. - */ - conflateNumbersInInference: boolean; debugPrintReconstitution: boolean; debugPrintSchemaResolving: boolean; debugPrintTransformations: boolean; @@ -196,14 +190,6 @@ class Run implements RunContext { return mapping; } - public get conflateNumbersInInference(): boolean { - const targetLanguage = getTargetLanguage(this._options.lang); - return !( - targetLanguage.supportsUnionsWithBothNumberTypes && - targetLanguage.infersUnionsWithBothNumberTypes - ); - } - public get debugPrintReconstitution(): boolean { return this._options.debugPrintReconstitution === true; } diff --git a/packages/quicktype-core/src/TargetLanguage.ts b/packages/quicktype-core/src/TargetLanguage.ts index 1a4ad310ce..f0617b7390 100644 --- a/packages/quicktype-core/src/TargetLanguage.ts +++ b/packages/quicktype-core/src/TargetLanguage.ts @@ -111,24 +111,6 @@ export abstract class TargetLanguage< return false; } - /** - * Whether inference from JSON samples keeps `integer` and `double` as - * separate union members when one value position mixes them, so that - * `[1, 1.5]` infers `integer | double` instead of `double`. - * - * Off by default: JSON does not distinguish `1` from `1.0`, and most - * languages have a single numeric type that fits both. A language whose - * integer and floating-point types are disjoint at runtime (Gleam's - * `Int` and `Float`) opts in so a whole number is not widened to a float. - * - * Only meaningful together with `supportsUnionsWithBothNumberTypes`; - * without it, later rewrites conflate the inferred union back into - * `double`. - */ - public get infersUnionsWithBothNumberTypes(): boolean { - return false; - } - public get supportsFullObjectType(): boolean { return false; } diff --git a/packages/quicktype-core/src/input/Inference.ts b/packages/quicktype-core/src/input/Inference.ts index 65d4a3caf1..4efca3c4f2 100644 --- a/packages/quicktype-core/src/input/Inference.ts +++ b/packages/quicktype-core/src/input/Inference.ts @@ -120,7 +120,6 @@ export class TypeInference { private readonly _typeBuilder: TypeBuilder, private readonly _inferMaps: boolean, private readonly _inferEnums: boolean, - private readonly _conflateNumbers: boolean, ) {} private addValuesToAccumulator( @@ -326,7 +325,7 @@ export class TypeInference { const accumulator = new UnionAccumulator< NestedValueArray, NestedValueArray - >(this._conflateNumbers); + >(true); this.addValuesToAccumulator(valueArray, accumulator); return accumulator; } @@ -399,7 +398,7 @@ export class TypeInference { const accumulator = new UnionAccumulator< NestedValueArray, NestedValueArray - >(this._conflateNumbers); + >(true); for (const key of propertyNames) { this.addValuesToAccumulator(propertyValues[key], accumulator); } diff --git a/packages/quicktype-core/src/input/Inputs.ts b/packages/quicktype-core/src/input/Inputs.ts index 6fa372e9b0..65c31448de 100644 --- a/packages/quicktype-core/src/input/Inputs.ts +++ b/packages/quicktype-core/src/input/Inputs.ts @@ -172,7 +172,7 @@ export class JSONInput implements Input> { } public addTypesSync( - ctx: RunContext, + _ctx: RunContext, typeBuilder: TypeBuilder, inferMaps: boolean, inferEnums: boolean, @@ -183,7 +183,6 @@ export class JSONInput implements Input> { typeBuilder, inferMaps, inferEnums, - ctx.conflateNumbersInInference, ); for (const [name, { samples, description }] of this._topLevels) { diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index 346373112d..eebbb8a9f5 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -1,5 +1,3 @@ -import { iterableSome } from "collection-utils"; - import { ConvenienceRenderer, type ForbiddenWordsInfo, @@ -14,7 +12,6 @@ import { ClassType, type EnumType, type Type, - type TypeKind, UnionType, } from "../../Type/index.js"; @@ -451,9 +448,8 @@ export class GleamRenderer extends ConvenienceRenderer { (_integerType) => text("decode.int"), // On Erlang `decode.float` rejects an integral JSON number, so a // `number` field must accept an int and widen it. - (_doubleType) => { - this._needsInt = true; - return call("decode.one_of", [ + (_doubleType) => + call("decode.one_of", [ text("decode.float"), list([ call("decode.map", [ @@ -461,8 +457,7 @@ export class GleamRenderer extends ConvenienceRenderer { text("int.to_float"), ]), ]), - ]); - }, + ]), (_stringType) => text("decode.string"), (arrayType) => call("decode.list", [this.decoderDoc(arrayType.items)]), @@ -873,46 +868,24 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(2, "use <- decode.recursive"); } - // A union of `Int` and `Float` decodes each with its strict decoder, - // and tries `Int` first: the lenient `number` decoder would claim - // every whole number for the `Float` variant, and on the JavaScript - // target `decode.float` accepts integral numbers too. - const isNumberUnion = - iterableSome(nonNulls, (t) => t.kind === "integer") && - iterableSome(nonNulls, (t) => t.kind === "double"); - - const entries: Array<{ kind: TypeKind; variant: Doc }> = []; + const members: Doc[] = []; + let anyMember: Doc | undefined; this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { - const decoder = - isNumberUnion && t.kind === "double" - ? text("decode.float") - : this.decoderDoc(t); - entries.push({ - kind: t.kind, - variant: call("decode.map", [ - decoder, - text(this.nameToString(memberName)), - ]), - }); - }); - - if (isNumberUnion) { - const integerIndex = entries.findIndex((e) => e.kind === "integer"); - const doubleIndex = entries.findIndex((e) => e.kind === "double"); - if (integerIndex > doubleIndex) { - const [integerEntry] = entries.splice(integerIndex, 1); - entries.splice(doubleIndex, 0, integerEntry); + const variant = call("decode.map", [ + this.decoderDoc(t), + text(this.nameToString(memberName)), + ]); + // Rule 3: `JsonValue` matches every input, so its variant must come + // last or it would shadow the others. + if (t.kind === "any") { + anyMember = variant; + } else { + members.push(variant); } - } + }); - // Rule 3: `JsonValue` matches every input, so its variant must come - // last or it would shadow the others. - const members = entries - .filter((e) => e.kind !== "any") - .map((e) => e.variant); - const anyEntry = entries.find((e) => e.kind === "any"); - if (anyEntry !== undefined) { - members.push(anyEntry.variant); + if (anyMember !== undefined) { + members.push(anyMember); } this.emitOneOf(members); @@ -998,6 +971,11 @@ export class GleamRenderer extends ConvenienceRenderer { if (t.kind === "map") { this._needsDict = true; } + + // The `number` decoder widens an int with `int.to_float`. + if (t.kind === "double") { + this._needsInt = true; + } } // The `JsonObject` variant of `JsonValue` carries a `Dict`. @@ -1009,10 +987,6 @@ export class GleamRenderer extends ConvenienceRenderer { protected emitSourceStructure(): void { this.computeNeeds(); - // The body decides whether `gleam/int` is imported (only the lenient - // `number` decoder uses it), so gather it before writing the header. - const body = this.gatherSource(() => this.emitBody()); - this.line(0, "// Generated by quicktype"); this.line(0, "//"); this.line( @@ -1040,10 +1014,6 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "import gleam/option"); } - this.emitGatheredSource(body); - } - - private emitBody(): void { if (this._needsJsonValue) { this.line(0); this.emitJsonValueType(); diff --git a/packages/quicktype-core/src/language/Gleam/language.ts b/packages/quicktype-core/src/language/Gleam/language.ts index f5108b3caa..82c4d08b89 100644 --- a/packages/quicktype-core/src/language/Gleam/language.ts +++ b/packages/quicktype-core/src/language/Gleam/language.ts @@ -33,13 +33,6 @@ export class GleamTargetLanguage extends TargetLanguage< return true; } - // `Int` and `Float` are distinct types, so JSON samples that mix - // `123` and `12.3` infer an `Int | Float` union rather than widening - // every whole number to a `Float` that re-encodes as `123.0`. - public get infersUnionsWithBothNumberTypes(): boolean { - return true; - } - protected makeRenderer(renderContext: RenderContext): GleamRenderer { return new GleamRenderer(this, renderContext); } diff --git a/test/inputs/json/priority/int-float-union.json b/test/inputs/json/priority/int-float-union.json deleted file mode 100644 index bc15df22c9..0000000000 --- a/test/inputs/json/priority/int-float-union.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "whole_or_fraction": 123, - "number_or_text": 123, - "maybe_number": 1, - "numbers": [1, 2.5], - "fraction": 1.5, - "whole": 1 - }, - { - "whole_or_fraction": 12.3, - "number_or_text": "Hello Dolly", - "maybe_number": 2.5, - "numbers": [3], - "fraction": 2.5, - "whole": 2 - }, - { - "whole_or_fraction": 7, - "number_or_text": 12.3, - "maybe_number": null, - "numbers": [], - "fraction": 0.5, - "whole": 3 - } -] diff --git a/test/languages.ts b/test/languages.ts index 3c4793034b..f6f189a5ba 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -856,13 +856,12 @@ export const GleamLanguage: Language = { runCommand(sample: string) { return `gleam run -- "${sample}"`; }, - // The JSON path infers an `Int | Float` union wherever samples mix whole - // and fractional numbers, but JSON Schema can only say `number`, so code - // generated via the schema collapses those unions to `Float` and can never - // be byte-identical. Compile and round-trip the schema-path code instead. - diffViaSchema: false, - roundtripViaSchema: true, - skipDiffViaSchema: [], + diffViaSchema: true, + // These inputs name types/fields/enum cases differently on the JSON and + // JSON-Schema paths (e.g. `link_type` vs `link_link_type`, enum case + // `Leaddatum` vs `Purple`); the generated Gleam is correct either way but + // not byte-identical. Every diffViaSchema language skips this class. + skipDiffViaSchema: ["bug427.json", "github-events.json", "recursive.json"], allowMissingNull: true, features: ["union", "integer"], output: "src/quicktype.gleam", diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts index e64ca47878..b8bb88bb41 100644 --- a/test/unit/gleam-decoders.test.ts +++ b/test/unit/gleam-decoders.test.ts @@ -178,62 +178,10 @@ test("the universal `JsonValue` catch-all is emitted last", async () => { expect(decoder.slice(catchAll)).not.toContain("decode.map"); }); -// Number unions. `Int` and `Float` are distinct Gleam types, so JSON samples -// that mix whole and fractional numbers infer an `Int | Float` union instead -// of widening every whole number to a `Float`. Fixture round trips cannot pin -// this: the comparison parses JSON, so `123` and `123.0` compare equal. -test("JSON samples mixing whole and fractional numbers infer an Int | Float union", async () => { - const output = await gleamFromJSON("Bar", [ - JSON.stringify([{ foo: 123 }, { foo: 12.3 }]), - ]); - - expect(output).toContain( - ["pub type Foo {", " FooDouble(Float)", " FooInteger(Int)", "}"].join( - "\n", - ), - ); -}); - -test("a number union alongside another type keeps both number variants", async () => { - const output = await gleamFromJSON("Bar", [ - JSON.stringify([{ foo: 123 }, { foo: "Hello Dolly" }, { foo: 12.3 }]), - ]); - - expect(output).toContain( - [ - "pub type Foo {", - " FooDouble(Float)", - " FooInteger(Int)", - " FooString(String)", - "}", - ].join("\n"), - ); -}); - -// The lenient `number` decoder accepts a whole number, and on the JavaScript -// target so does `decode.float`, so the `Int` variant must be tried first and -// the `Float` variant decoded strictly. Ordered the other way, `123` decodes -// as `FooDouble(123.0)` and `FooInteger` is unreachable. -test("an Int | Float union tries Int first and decodes Float strictly", async () => { - const output = await gleamFromJSON("Bar", [ - JSON.stringify([{ foo: 123 }, { foo: "Hello Dolly" }, { foo: 12.3 }]), - ]); - - expect(output).toContain( - [ - "pub fn foo_decoder() -> decode.Decoder(Foo) {", - " decode.one_of(decode.map(decode.int, FooInteger), [", - " decode.map(decode.float, FooDouble),", - " decode.map(decode.string, FooString),", - " ])", - "}", - ].join("\n"), - ); - // Nothing widens an int here, so `gleam/int` is not imported. - expect(output).not.toContain("int.to_float"); - expect(output).not.toContain("import gleam/int"); -}); - +// `Int` and `Float` are distinct Gleam types, but quicktype widens mixed +// number samples to a `Float` field, so a whole number like `123` must still +// decode. `decode.float` rejects ints on the Erlang target, hence the lenient +// `one_of` with an `int.to_float` fallback. test("a plain Float field keeps the lenient number decoder and its import", async () => { const output = await gleamFromJSON("Bar", [ JSON.stringify([{ foo: 1.5 }, { foo: 2.5 }]), From 8369b8ccb95ca2d57ee8a68f9c001dd18ebab4a9 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 21 Sep 2026 09:20:10 -0400 Subject: [PATCH 17/19] fix(gleam): stop emitting a type alias for a top-level The alias was dead code. Drops the comments that described it. --- .../src/language/Gleam/GleamRenderer.ts | 59 ++++++++----------- .../src/language/Gleam/pretty.ts | 21 +++++++ test/languages.ts | 2 +- test/unit/gleam-decoders.test.ts | 2 +- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index eebbb8a9f5..e6f4939285 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -30,6 +30,7 @@ import { lambda, list, render, + renderType, seq, text, trailingSlack, @@ -159,7 +160,7 @@ export class GleamRenderer extends ConvenienceRenderer { topLevelName: Name, ): DependencyName[] { // Named-type top-levels already have conversion functions from their - // named-type dependency names; only aliases need their own. + // named-type dependency names; only structural ones need their own. if (this.namedTypeToNameForTopLevel(t) !== undefined) { return []; } @@ -560,31 +561,29 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "}"); } - private emitTopLevelAlias(t: Type, name: Name): void { - // `gleam format` always places an aliased type on its own line. - this.line(0, `pub type ${this.nameToString(name)} =`); - this.emitDoc(2, this.typeDoc(t)); - } - // Emit a `pub fn name(value: Type) -> Return {` header, breaking the // parameter list onto its own line when the one-line form exceeds the // 80-column limit, exactly as `gleam format` does. private emitFnHeader( fnName: string, - paramType: string, + paramType: Doc, returnType: string, paramName = "value", ): void { // `gleam format` measures the signature width without the trailing // ` {` block opener. - const signature = `pub fn ${fnName}(${paramName}: ${paramType}) -> ${returnType}`; + const signature = `pub fn ${fnName}(${paramName}: ${flat(paramType)}) -> ${returnType}`; if (signature.length <= MAX_WIDTH) { this.line(0, `${signature} {`); return; } + const prefix = `${paramName}: `; this.line(0, `pub fn ${fnName}(`); - this.line(2, `${paramName}: ${paramType},`); + this.line( + 2, + `${prefix}${renderType(paramType, 2, 2 + prefix.length)},`, + ); this.line(0, `) -> ${returnType} {`); } @@ -618,7 +617,7 @@ export class GleamRenderer extends ConvenienceRenderer { // A property-less record's encoder ignores its argument. const paramName = required.length === 0 && optional.length === 0 ? "_value" : "value"; - this.emitFnHeader(encoder, typeName, "json.Json", paramName); + this.emitFnHeader(encoder, text(typeName), "json.Json", paramName); if (optional.length === 0) { this.emitDoc(2, call("json.object", [list(required)])); } else { @@ -655,7 +654,7 @@ export class GleamRenderer extends ConvenienceRenderer { private emitEnumEncoder(e: EnumType, enumName: Name): void { const typeName = this.nameToString(enumName); const encoder = this.encoderName(e); - this.emitFnHeader(encoder, typeName, "json.Json"); + this.emitFnHeader(encoder, text(typeName), "json.Json"); this.line(2, "case value {"); this.forEachEnumCase(e, "none", (caseName, jsonName) => { this.emitBinding( @@ -673,7 +672,7 @@ export class GleamRenderer extends ConvenienceRenderer { const typeName = this.nameToString(unionName); const encoder = this.encoderName(u); const [, nonNulls] = removeNullFromUnion(u); - this.emitFnHeader(encoder, typeName, "json.Json"); + this.emitFnHeader(encoder, text(typeName), "json.Json"); this.line(2, "case value {"); this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { this.emitBinding( @@ -687,15 +686,18 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "}"); } + // A top level that is not a named type — a list, a dict, or a primitive — + // has no declaration of its own. Gleam discourages type aliases, so the + // conversion functions spell the structural type out in their signatures + // instead of hiding it behind `pub type TopLevel = List(Person)`. private emitTopLevelEncoder(t: Type, name: Name): void { const functions = this._topLevelFunctions.get(name); if (functions === undefined) { return; } - const typeName = this.nameToString(name); const encoder = this.nameToString(functions.encoder); - this.emitFnHeader(encoder, typeName, "json.Json"); + this.emitFnHeader(encoder, this.typeDoc(t), "json.Json"); this.emitDoc(2, this.encodeDoc(t, text("value"))); this.line(0, "}"); } @@ -713,7 +715,7 @@ export class GleamRenderer extends ConvenienceRenderer { } private emitJsonValueEncoder(): void { - this.emitFnHeader("json_value_to_json", "JsonValue", "json.Json"); + this.emitFnHeader("json_value_to_json", text("JsonValue"), "json.Json"); this.line(2, "case value {"); this.line(4, "JsonNull -> json.null()"); this.line(4, "JsonBool(inner) -> json.bool(inner)"); @@ -741,21 +743,21 @@ export class GleamRenderer extends ConvenienceRenderer { // Emit a `pub fn name() -> decode.Decoder(Type) {` header, breaking the // return type when the one-line form exceeds 80 columns. - private emitDecoderHeader(fnName: string, typeName: string): void { - const signature = `pub fn ${fnName}() -> decode.Decoder(${typeName})`; + private emitDecoderHeader(fnName: string, returnType: Doc): void { + const signature = `pub fn ${fnName}() -> decode.Decoder(${flat(returnType)})`; if (signature.length <= MAX_WIDTH) { this.line(0, `${signature} {`); return; } this.line(0, `pub fn ${fnName}() -> decode.Decoder(`); - this.line(2, `${typeName},`); + this.line(2, `${renderType(returnType, 2, 2)},`); this.line(0, ") {"); } private emitClassDecoder(c: ClassType, className: Name): void { const typeName = this.nameToString(className); - this.emitDecoderHeader(this.decoderName(c), typeName); + this.emitDecoderHeader(this.decoderName(c), text(typeName)); // Rule 1: a decoder that reaches itself and omits `decode.recursive` // compiles clean, then hangs forever at construction time. The guard // re-runs the body on every decode, so emit it only where needed. @@ -827,7 +829,7 @@ export class GleamRenderer extends ConvenienceRenderer { private emitEnumDecoder(e: EnumType, enumName: Name): void { const typeName = this.nameToString(enumName); - this.emitDecoderHeader(this.decoderName(e), typeName); + this.emitDecoderHeader(this.decoderName(e), text(typeName)); this.emitUse( 2, "variant", @@ -863,7 +865,7 @@ export class GleamRenderer extends ConvenienceRenderer { private emitUnionDecoder(u: UnionType, unionName: Name): void { const typeName = this.nameToString(unionName); const [, nonNulls] = removeNullFromUnion(u); - this.emitDecoderHeader(this.decoderName(u), typeName); + this.emitDecoderHeader(this.decoderName(u), text(typeName)); if (this.isCycleBreakerType(u)) { this.line(2, "use <- decode.recursive"); } @@ -907,7 +909,7 @@ export class GleamRenderer extends ConvenienceRenderer { this.emitDecoderHeader( this.nameToString(functions.decoder), - this.nameToString(name), + this.typeDoc(t), ); if (this.isCycleBreakerType(t)) { this.line(2, "use <- decode.recursive"); @@ -917,7 +919,7 @@ export class GleamRenderer extends ConvenienceRenderer { } private emitJsonValueDecoder(): void { - this.emitDecoderHeader("json_value_decoder", "JsonValue"); + this.emitDecoderHeader("json_value_decoder", text("JsonValue")); // Always guarded: the JsonArray and JsonObject variants below call // `json_value_decoder()` in eager argument position, so this decoder // is literally self-recursive. @@ -1031,15 +1033,6 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0); this.emitUnionType(u, name); }); - this.forEachTopLevel( - "none", - (t, name) => { - this.line(0); - this.emitTopLevelAlias(t, name); - }, - (t) => this.namedTypeToNameForTopLevel(t) === undefined, - ); - if (this._needsJsonValue) { this.line(0); this.emitJsonValueEncoder(); diff --git a/packages/quicktype-core/src/language/Gleam/pretty.ts b/packages/quicktype-core/src/language/Gleam/pretty.ts index 6ef2428fd5..daf7edcb51 100644 --- a/packages/quicktype-core/src/language/Gleam/pretty.ts +++ b/packages/quicktype-core/src/language/Gleam/pretty.ts @@ -200,6 +200,27 @@ function renderCallBroken( return out; } +// Render a type annotation. `gleam format` breaks a type that does not fit by +// moving every argument of the outermost constructor onto its own line; unlike +// an expression, a type never hugs its last argument. As elsewhere in the +// formatter, the trailing comma of a broken argument does not count toward the +// 80-column limit. +export function renderType(doc: Doc, indent: number, column: number): string { + const flatText = flat(doc); + if (doc.kind !== "call" || column + flatText.length <= MAX_WIDTH) { + return flatText; + } + + const childIndent = indent + INDENT; + let out = `${doc.head}(\n`; + for (const arg of doc.args) { + out += `${pad(childIndent)}${renderType(arg, childIndent, childIndent)},\n`; + } + + out += `${pad(indent)})`; + return out; +} + export function render(doc: Doc, indent: number, column: number): Rendered { switch (doc.kind) { case "text": diff --git a/test/languages.ts b/test/languages.ts index f6f189a5ba..9fe7a21a4d 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -872,7 +872,7 @@ export const GleamLanguage: Language = { // unlimited stack). "keywords.json", "nst-test-suite.json", - // A 280-character JSON key becomes a 270-character Gleam identifier, + // A 277-character JSON key becomes a Gleam identifier of similar length, // which the Erlang backend emits as an Erlang variable of the same // length. Erlang's scanner interns variable names as atoms, capped at // 255 characters, so erlc rejects it with "illegal var". `gleam check` diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts index b8bb88bb41..49504c0197 100644 --- a/test/unit/gleam-decoders.test.ts +++ b/test/unit/gleam-decoders.test.ts @@ -132,7 +132,7 @@ test("an enum decoder is unguarded", async () => { expect(guarded).toEqual([]); }); -test("a top-level array alias decoder is unguarded", async () => { +test("a top-level array decoder is unguarded", async () => { const output = await gleamFromSchema( JSON.stringify({ type: "array", From d98e866f51b039210ca0bfe4e7e0934f49dca6a0 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 21 Sep 2026 09:20:14 -0400 Subject: [PATCH 18/19] fix(gleam): match gleam format's line breaking An empty call stays on one line, and a function header is measured without its trailing brace. --- .../src/language/Gleam/GleamRenderer.ts | 24 ++++++++------- .../src/language/Gleam/pretty.ts | 30 ++++++++++++++++++- test/unit/gleam-decoders.test.ts | 24 ++++++++++++--- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index e6f4939285..7f849386cb 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -570,11 +570,10 @@ export class GleamRenderer extends ConvenienceRenderer { returnType: string, paramName = "value", ): void { - // `gleam format` measures the signature width without the trailing - // ` {` block opener. - const signature = `pub fn ${fnName}(${paramName}: ${flat(paramType)}) -> ${returnType}`; - if (signature.length <= MAX_WIDTH) { - this.line(0, `${signature} {`); + // The whole line, trailing ` {` included, must fit in 80 columns. + const header = `pub fn ${fnName}(${paramName}: ${flat(paramType)}) -> ${returnType} {`; + if (header.length <= MAX_WIDTH) { + this.line(0, header); return; } @@ -742,7 +741,11 @@ export class GleamRenderer extends ConvenienceRenderer { } // Emit a `pub fn name() -> decode.Decoder(Type) {` header, breaking the - // return type when the one-line form exceeds 80 columns. + // return type when the one-line form is too wide. + // + // Unlike `emitFnHeader`, the signature without the trailing ` {` is what + // must fit in 80 columns: this form stays on one line at 82 columns + // including the brace and breaks at 83. private emitDecoderHeader(fnName: string, returnType: Doc): void { const signature = `pub fn ${fnName}() -> decode.Decoder(${flat(returnType)})`; if (signature.length <= MAX_WIDTH) { @@ -758,7 +761,7 @@ export class GleamRenderer extends ConvenienceRenderer { private emitClassDecoder(c: ClassType, className: Name): void { const typeName = this.nameToString(className); this.emitDecoderHeader(this.decoderName(c), text(typeName)); - // Rule 1: a decoder that reaches itself and omits `decode.recursive` + // A decoder that reaches itself and omits `decode.recursive` // compiles clean, then hangs forever at construction time. The guard // re-runs the body on every decode, so emit it only where needed. if (this.isCycleBreakerType(c)) { @@ -795,7 +798,8 @@ export class GleamRenderer extends ConvenienceRenderer { ]), ]); } else { - // Optional key, nullable value — Rule 2: both combinators. + // Optional key, nullable value — accept a missing key and a + // null. decoder = call("decode.optional_field", [ key, text("option.None"), @@ -877,8 +881,8 @@ export class GleamRenderer extends ConvenienceRenderer { this.decoderDoc(t), text(this.nameToString(memberName)), ]); - // Rule 3: `JsonValue` matches every input, so its variant must come - // last or it would shadow the others. + // `JsonValue` matches every input, so its variant must come last + // or it would shadow the others. if (t.kind === "any") { anyMember = variant; } else { diff --git a/packages/quicktype-core/src/language/Gleam/pretty.ts b/packages/quicktype-core/src/language/Gleam/pretty.ts index daf7edcb51..8eaaae367c 100644 --- a/packages/quicktype-core/src/language/Gleam/pretty.ts +++ b/packages/quicktype-core/src/language/Gleam/pretty.ts @@ -106,7 +106,27 @@ export function trailingSlack(doc: Doc): number { return 0; } +// Whether a document has anything between its delimiters to break on. A call +// with no arguments, an empty list, and an empty tuple have no break point, so +// `gleam format` leaves them on one line however far past column 80 they sit. +function canBreak(doc: Doc): boolean { + switch (doc.kind) { + case "call": + return doc.args.length > 0; + case "list": + return doc.items.length > 0 || doc.spread !== null; + case "tuple": + return doc.items.length > 0; + default: + return true; + } +} + function fits(doc: Doc, column: number): boolean { + if (!canBreak(doc)) { + return true; + } + return column + flat(doc).length <= MAX_WIDTH + trailingSlack(doc); } @@ -153,6 +173,10 @@ function renderBrokenItems( // Force `doc` into its broken (multi-line) form. `column` is where the opening // delimiter sits, needed only for a nested call's own hug decision. function renderBroken(doc: Doc, indent: number, column: number): string { + if (!canBreak(doc)) { + return flat(doc); + } + switch (doc.kind) { case "list": return renderBrokenItems(doc.items, doc.spread, indent, "[", "]"); @@ -207,7 +231,11 @@ function renderCallBroken( // 80-column limit. export function renderType(doc: Doc, indent: number, column: number): string { const flatText = flat(doc); - if (doc.kind !== "call" || column + flatText.length <= MAX_WIDTH) { + if ( + doc.kind !== "call" || + !canBreak(doc) || + column + flatText.length <= MAX_WIDTH + ) { return flatText; } diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts index 49504c0197..919c40e006 100644 --- a/test/unit/gleam-decoders.test.ts +++ b/test/unit/gleam-decoders.test.ts @@ -25,8 +25,8 @@ async function gleamFromSchema(schema: string): Promise { return result.lines.join("\n"); } -// Rule 1 (highest severity): a decoder that reaches itself and omits -// `decode.recursive` compiles clean, then hangs forever at construction time. +// A decoder that reaches itself and omits `decode.recursive` compiles clean, +// then hangs forever at construction time. // Fixture tests cannot catch this — the job simply times out — so assert the // guarded set exactly here: a missing guard hangs, while a superfluous guard // rebuilds the decoder once per decoded value. @@ -153,8 +153,8 @@ test("a top-level array decoder is unguarded", async () => { expect(guarded).toEqual([]); }); -// Rule 3: `JsonValue` matches every input, so the variant that decodes to it -// must come last in a `one_of` or it would shadow the others. The bare `any` +// `JsonValue` matches every input, so the variant that decodes to it must +// come last in a `one_of` or it would shadow the others. The bare `any` // member is absorbed by the type IR, but the same "universal match last" // invariant is realized inside `json_value_decoder`, whose `decode.success` // catch-all always succeeds and therefore must be the final alternative. @@ -193,3 +193,19 @@ test("a plain Float field keeps the lenient number decoder and its import", asyn ); expect(output).toContain("import gleam/int"); }); + +// A call with no arguments has nothing to break on, so `gleam format` keeps it +// on one line however far past column 80 it sits. Splitting `head(` from `)` +// makes `gleam format --check src/` reject the module. A ~70-character decoder +// name nested three lists deep is the shortest input that reaches the limit. +test("a zero-argument call past column 80 stays on one line", async () => { + const output = await gleamFromJSON( + "AVeryLongTopLevelNameForTestingSignatureWrappingBehaviour", + [JSON.stringify([[[{ a: 1 }]]])], + ); + + expect(output).toContain( + "a_very_long_top_level_name_for_testing_signature_wrapping_behaviour_element_decoder(),", + ); + expect(output).not.toMatch(/_decoder\(\n\s*\)/); +}); From 0d8f31d0281c3cfccfad924f8607b08d9c36fdbb Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 21 Sep 2026 09:20:18 -0400 Subject: [PATCH 19/19] fix(gleam): keep a union's double variant strict beside an integer A lenient Float decoder swallows whole numbers, which leaves the Int variant unreachable. --- .../src/language/Gleam/GleamRenderer.ts | 56 ++++++++++++++++++- .../src/language/Gleam/language.ts | 7 ++- test/unit/gleam-decoders.test.ts | 31 ++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts index 7f849386cb..d3e5627d30 100644 --- a/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -9,8 +9,10 @@ import { removeNullFromUnion, } from "../../Type/TypeUtils.js"; import { + ArrayType, ClassType, type EnumType, + MapType, type Type, UnionType, } from "../../Type/index.js"; @@ -876,9 +878,20 @@ export class GleamRenderer extends ConvenienceRenderer { const members: Doc[] = []; let anyMember: Doc | undefined; + const hasIntegerVariant = Array.from(nonNulls).some( + (t) => t.kind === "integer", + ); this.forEachUnionMember(u, nonNulls, "none", null, (memberName, t) => { + // The lenient number decoder accepts integers too, so next to an + // integer variant it would shadow that variant whichever order + // `one_of` tries them in; the integer variant already covers + // integral input, so here the double variant demands a true float. + const memberDecoder = + hasIntegerVariant && t.kind === "double" + ? text("decode.float") + : this.decoderDoc(t); const variant = call("decode.map", [ - this.decoderDoc(t), + memberDecoder, text(this.nameToString(memberName)), ]); // `JsonValue` matches every input, so its variant must come last @@ -949,6 +962,36 @@ export class GleamRenderer extends ConvenienceRenderer { this.line(0, "}"); } + // Whether decoding `t` applies the lenient number decoder (`decode.float` + // with an `int.to_float` fallback) to a double child. + private hasLenientDouble(t: Type): boolean { + if (t instanceof ClassType) { + for (const [, p] of t.getProperties()) { + if (p.type.kind === "double") { + return true; + } + } + + return false; + } + + if (t instanceof UnionType) { + const [, nonNulls] = removeNullFromUnion(t); + const kinds = new Set(Array.from(nonNulls).map((m) => m.kind)); + return kinds.has("double") && !kinds.has("integer"); + } + + if (t instanceof ArrayType) { + return t.items.kind === "double"; + } + + if (t instanceof MapType) { + return t.values.kind === "double"; + } + + return false; + } + private computeNeeds(): void { for (const t of this.typeGraph.allTypesUnordered()) { if (t.kind === "any") { @@ -978,7 +1021,16 @@ export class GleamRenderer extends ConvenienceRenderer { this._needsDict = true; } - // The `number` decoder widens an int with `int.to_float`. + // The lenient number decoder widens an int with `int.to_float`; + // a union double next to an integer variant decodes strictly and + // does not need `gleam/int`. + if (this.hasLenientDouble(t)) { + this._needsInt = true; + } + } + + // A bare double top level also decodes with the lenient decoder. + for (const t of this.topLevels.values()) { if (t.kind === "double") { this._needsInt = true; } diff --git a/packages/quicktype-core/src/language/Gleam/language.ts b/packages/quicktype-core/src/language/Gleam/language.ts index 82c4d08b89..1e29011730 100644 --- a/packages/quicktype-core/src/language/Gleam/language.ts +++ b/packages/quicktype-core/src/language/Gleam/language.ts @@ -26,9 +26,10 @@ export class GleamTargetLanguage extends TargetLanguage< return true; } - // On Erlang, `decode.int` and `decode.float` are disjoint, so a - // number union decodes both without the integer-before-number hazard - // that bites renderers sharing one numeric decoder. + // On Erlang, `decode.int` and `decode.float` are disjoint, and the + // union decoder keeps its double variant strict — no `int.to_float` + // widening — when an integer variant is present, so neither numeric + // variant can swallow the other's values. public get supportsUnionsWithBothNumberTypes(): boolean { return true; } diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts index 919c40e006..5a2ac1e83c 100644 --- a/test/unit/gleam-decoders.test.ts +++ b/test/unit/gleam-decoders.test.ts @@ -194,6 +194,37 @@ test("a plain Float field keeps the lenient number decoder and its import", asyn expect(output).toContain("import gleam/int"); }); +test("a union's double variant decodes strictly beside an integer variant", async () => { + const output = await gleamFromSchema( + JSON.stringify({ + type: "object", + properties: { + reading: { oneOf: [{ type: "integer" }, { type: "number" }] }, + ratio: { type: "number" }, + }, + required: ["reading", "ratio"], + }), + ); + + expect(output).toContain("ReadingDouble(Float)"); + expect(output).toContain("ReadingInteger(Int)"); + + // Slice out the union's own decoder: searching the whole module would + // match `ratio`'s lenient decoder and pin the wrong thing. + const start = output.indexOf("pub fn reading_decoder()"); + expect(start).toBeGreaterThanOrEqual(0); + const decoder = output.slice(start, output.indexOf("\n}", start)); + + expect(decoder).toContain("decode.map(decode.float, ReadingDouble)"); + expect(decoder).not.toContain("int.to_float"); + + // `ratio` still widens an int, so the import it needs stays. + expect(output).toContain( + "decode.one_of(decode.float, [decode.map(decode.int, int.to_float)])", + ); + expect(output).toContain("import gleam/int"); +}); + // A call with no arguments has nothing to break on, so `gleam format` keeps it // on one line however far past column 80 it sits. Splitting `head(` from `)` // makes `gleam format --check src/` reject the module. A ~70-character decoder