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' }} 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-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/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..346373112d --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/GleamRenderer.ts @@ -0,0 +1,1125 @@ +import { iterableSome } from "collection-utils"; + +import { + ConvenienceRenderer, + type ForbiddenWordsInfo, +} from "../../ConvenienceRenderer.js"; +import { DependencyName, type Name, type Namer } from "../../Naming.js"; +import { + matchType, + nullableFromUnion, + removeNullFromUnion, +} from "../../Type/TypeUtils.js"; +import { + ClassType, + type EnumType, + type Type, + type TypeKind, + UnionType, +} from "../../Type/index.js"; + +import { + forbiddenModuleNames, + jsonValueNames, + keywords, + preludeNames, +} from "./constants.js"; +import { + type Doc, + MAX_WIDTH, + call, + flat, + isCollection, + lambda, + list, + render, + seq, + text, + trailingSlack, + 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; + + private _needsInt = false; + + protected makeNamedTypeNamer(): Namer { + return pascalNamingFunction; + } + + protected namerForObjectProperty(): Namer | null { + return snakeNamingFunction; + } + + protected makeUnionMemberNamer(): Namer | null { + return pascalNamingFunction; + } + + protected makeEnumCaseNamer(): Namer | null { + 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, + ...jsonValueNames, + ...preludeNames, + ]; + } + + 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 }; + } + + // 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( + 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 { + 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 functions; + } + + // 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]); + }, + ); + } + + 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)); + }, + ); + } + + private isNullableType(t: Type): boolean { + if (t instanceof UnionType) { + if (nullableFromUnion(t) !== null) { + return true; + } + + const [hasNull] = removeNullFromUnion(t); + return hasNull !== null; + } + + return false; + } + + // Both absent (optional) and null (nullable) properties map to `Option`. + private isGleamOptional(t: Type, isOptional: boolean): boolean { + return isOptional || this.isNullableType(t); + } + + 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 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) => { + this._needsInt = true; + return 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), []); + }, + ); + } + + 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) { + 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, + paramName = "value", + ): void { + // `gleam format` measures the signature width without the trailing + // ` {` block opener. + 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, `${paramName}: ${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)}`), + ), + ), + ); + } + }); + + // 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 { + 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, "}"); + } + + // 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: 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) => { + const binding = this.nameToString(name); + 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 { + // 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}`)); + }); + + 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, "}"); + } + + private emitEnumDecoder(e: EnumType, enumName: Name): void { + const typeName = this.nameToString(enumName); + this.emitDecoderHeader(this.decoderName(e), typeName); + 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); + if (this.isCycleBreakerType(u)) { + 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 }> = []; + 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); + } + } + + // 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); + 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), + ); + 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")]), + 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") { + 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.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( + 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/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"); + } + + this.emitGatheredSource(body); + } + + private emitBody(): void { + 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, + ); + + 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/constants.ts b/packages/quicktype-core/src/language/Gleam/constants.ts new file mode 100644 index 0000000000..df10e995d2 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/constants.ts @@ -0,0 +1,78 @@ +// 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; + +// 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/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..f5108b3caa --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/language.ts @@ -0,0 +1,54 @@ +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; + } + + 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; + } + + // `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); + } + + protected get defaultIndentation(): string { + return " "; + } + + public getOptions(): Record { + return {}; + } +} 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..6ef2428fd5 --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/pretty.ts @@ -0,0 +1,243 @@ +// 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. 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; + +export type Doc = + | { kind: "text"; text: string } + | { kind: "seq"; parts: Doc[] } + | { kind: "call"; head: string; args: 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 }; +} + +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"; +} + +// 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); +} + +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 "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; +} + +// 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 isHuggable(doc.args[doc.args.length - 1]) ? 1 : 0; + } + + return 0; +} + +function fits(doc: Doc, column: number): boolean { + return column + flat(doc).length <= MAX_WIDTH + trailingSlack(doc); +} + +// 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[], + 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; +} + +// 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": + 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 }; + } + + return { + text: renderCallBroken(doc, indent, column), + endColumn: indent + 2, + }; + } + case "lambda": + case "tuple": + case "list": { + if (fits(doc, column)) { + const flatText = flat(doc); + return { text: flatText, endColumn: column + flatText.length }; + } + + return { + text: renderBroken(doc, indent, column), + endColumn: indent + 1, + }; + } + } +} 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..e283a97b4c --- /dev/null +++ b/packages/quicktype-core/src/language/Gleam/utils.ts @@ -0,0 +1,76 @@ +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); +} + +// 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) && codePoint !== 0x5f; +} + +const legalizeName = legalizeCharacters(isAsciiLetterOrUnderscoreOrDigit); + +function gleamStyle(original: string, isSnakeCase: boolean): string { + const words = splitIntoWords(original); + + const wordStyle = isSnakeCase ? allLowerWordStyle : firstUpperWordStyle; + + return combineWords( + words, + legalizeName, + wordStyle, + wordStyle, + wordStyle, + wordStyle, + isSnakeCase ? "_" : "", + isAsciiLetter, + ); +} + +// 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)}}`; +} + +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"; 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 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/.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 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/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 + } +] diff --git a/test/languages.ts b/test/languages.ts index 8f534c31d2..3c4793034b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -837,6 +837,64 @@ 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 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 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}"`; + }, + // 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", + 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", + // 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 + // 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", diff --git a/test/unit/gleam-decoders.test.ts b/test/unit/gleam-decoders.test.ts new file mode 100644 index 0000000000..e64ca47878 --- /dev/null +++ b/test/unit/gleam-decoders.test.ts @@ -0,0 +1,247 @@ +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 +// 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 decoders: string[] = []; + const guarded: string[] = []; + lines.forEach((line, index) => { + const match = /^pub fn (\w+_decoder)\(/.exec(line); + if (match === null) { + return; + } + + 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 = index; + while (!lines[bodyIndex].endsWith("{")) { + bodyIndex += 1; + } + + 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 +// 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"); +}); + +// 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"); +});