From 48001815ca588392690f31aea7add4e65aa57ccc Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 08:42:21 -0400 Subject: [PATCH 1/3] fix(elm): generate Elm 0.19 code Modernize the Elm renderer from Elm 0.18 (which elm.org no longer supports) to Elm 0.19: - Seed decode pipelines with `Jdec.succeed` instead of the `decode` function that was removed in NoRedInk/elm-json-decode-pipeline 1.0. - Use the 0.19 elm/json encoder API: `Json.Encode.list`/`array` now take the element encoder directly and `Json.Encode.dict` exists, so the generated `makeArrayEncoder`/`makeListEncoder`/`makeDictEncoder` helpers are gone; only `makeNullableEncoder` remains. - Stop exposing `map`/`toList` from imports (Elm 0.19 shadowing rules); only `Dict` (and `Array` when needed) are exposed. - Forbid the parameter names used by generated functions (`x`, `y`, `r`, `f`, `m`, `str`, `somethingElse`) as global identifiers: Elm 0.19 does not allow a parameter to shadow a top-level definition. - Never start generated identifiers with an underscore, which Elm 0.19 rejects. - Escape non-printable characters in string literals with Elm's `\u{XXXX}` syntax; the previously used `\uXXXX`/`\UXXXXXXXX` forms are syntax errors in Elm 0.19. - Update the usage comment to `elm install NoRedInk/elm-json-decode-pipeline` and fix a stray backtick in it. Elm 0.18 output is not supported anymore. Co-Authored-By: Claude Fable 5 --- .../src/language/Elm/ElmRenderer.ts | 54 ++++++------------- .../src/language/Elm/constants.ts | 13 +++-- .../quicktype-core/src/language/Elm/utils.ts | 17 +++++- 3 files changed, 40 insertions(+), 44 deletions(-) diff --git a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts index 72acf1b41e..3f0e914064 100644 --- a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts +++ b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts @@ -19,7 +19,7 @@ import { parenIfNeeded, singleWord, } from "../../Source.js"; -import { decapitalize, stringEscape } from "../../support/Strings.js"; +import { decapitalize } from "../../support/Strings.js"; import { defined } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; import type { @@ -34,6 +34,7 @@ import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { forbiddenNames } from "./constants.js"; import type { elmOptions } from "./language.js"; import { + elmStringEscape, lowerNamingFunction, requiredOrOptional, upperNamingFunction, @@ -301,14 +302,15 @@ export class ElmRenderer extends ConvenienceRenderer { (arrayType) => multiWord( " ", - ["make", this.arrayType, "Encoder"], + ["Jenc.", decapitalize(this.arrayType)], parenIfNeeded(this.encoderNameForType(arrayType.items)), ), (classType) => singleWord(this.encoderNameForNamedType(classType)), (mapType) => multiWord( " ", - "makeDictEncoder", + "Jenc.dict", + "identity", parenIfNeeded(this.encoderNameForType(mapType.values)), ), (enumType) => singleWord(this.encoderNameForNamedType(enumType)), @@ -456,7 +458,7 @@ export class ElmRenderer extends ConvenienceRenderer { this.emitLine(decoderName, " : Jdec.Decoder ", className); this.emitLine(decoderName, " ="); this.indent(() => { - this.emitLine("Jpipe.decode ", className); + this.emitLine("Jdec.succeed ", className); this.indent(() => { this.forEachClassProperty(c, "none", (_, jsonName, p) => { const propDecoder = parenIfNeeded( @@ -467,7 +469,7 @@ export class ElmRenderer extends ConvenienceRenderer { "|> ", reqOrOpt, ' "', - stringEscape(jsonName), + elmStringEscape(jsonName), '" ', propDecoder, fallback, @@ -490,7 +492,7 @@ export class ElmRenderer extends ConvenienceRenderer { this.emitLine( bracketOrComma, ' ("', - stringEscape(jsonName), + elmStringEscape(jsonName), '", ', propEncoder, " x.", @@ -522,7 +524,7 @@ export class ElmRenderer extends ConvenienceRenderer { this.forEachEnumCase(e, "none", (name, jsonName) => { this.emitLine( '"', - stringEscape(jsonName), + elmStringEscape(jsonName), '" -> Jdec.succeed ', name, ); @@ -547,7 +549,7 @@ export class ElmRenderer extends ConvenienceRenderer { this.emitLine( name, ' -> Jenc.string "', - stringEscape(jsonName), + elmStringEscape(jsonName), '"', ); }); @@ -652,11 +654,11 @@ export class ElmRenderer extends ConvenienceRenderer { this.emitCommentLines([ "To decode the JSON data, add this file to your project, run", "", - " elm-package install NoRedInk/elm-decode-pipeline", + " elm install NoRedInk/elm-json-decode-pipeline", "", "add these imports", "", - " import Json.Decode exposing (decodeString)`);", + " import Json.Decode exposing (decodeString)", ]); this.emitLine( "-- import ", @@ -695,11 +697,9 @@ export class ElmRenderer extends ConvenienceRenderer { this.emitMultiline(`import Json.Decode as Jdec import Json.Decode.Pipeline as Jpipe import Json.Encode as Jenc -import Dict exposing (Dict, map, toList)`); - if (this._options.useList) { - this.emitLine("import List exposing (map)"); - } else { - this.emitLine("import Array exposing (Array, map)"); +import Dict exposing (Dict)`); + if (!this._options.useList) { + this.emitLine("import Array exposing (Array)"); } } @@ -741,29 +741,7 @@ import Dict exposing (Dict, map, toList)`); this.emitLine("--- encoder helpers"); this.ensureBlankLine(); - this.emitLine( - "make", - this.arrayType, - "Encoder : (a -> Jenc.Value) -> ", - this.arrayType, - " a -> Jenc.Value", - ); - this.emitLine("make", this.arrayType, "Encoder f arr ="); - this.indent(() => { - this.emitLine( - "Jenc.", - decapitalize(this.arrayType), - " (", - this.arrayType, - ".map f arr)", - ); - }); - this.ensureBlankLine(); - this.emitMultiline(`makeDictEncoder : (a -> Jenc.Value) -> Dict String a -> Jenc.Value -makeDictEncoder f dict = - Jenc.object (toList (Dict.map (\\k -> f) dict)) - -makeNullableEncoder : (a -> Jenc.Value) -> Maybe a -> Jenc.Value + this.emitMultiline(`makeNullableEncoder : (a -> Jenc.Value) -> Maybe a -> Jenc.Value makeNullableEncoder f m = case m of Just x -> f x diff --git a/packages/quicktype-core/src/language/Elm/constants.ts b/packages/quicktype-core/src/language/Elm/constants.ts index 5b3d119736..5f66253a27 100644 --- a/packages/quicktype-core/src/language/Elm/constants.ts +++ b/packages/quicktype-core/src/language/Elm/constants.ts @@ -27,11 +27,16 @@ export const forbiddenNames = [ "List", "Dict", "Maybe", - "map", - "toList", - "makeArrayEncoder", - "makeDictEncoder", "makeNullableEncoder", + // Parameter names used in generated functions. Elm 0.19 does not + // allow a parameter to shadow a top-level definition. + "x", + "y", + "r", + "f", + "m", + "str", + "somethingElse", "Int", "True", "False", diff --git a/packages/quicktype-core/src/language/Elm/utils.ts b/packages/quicktype-core/src/language/Elm/utils.ts index 17f12794ea..9083d68dce 100644 --- a/packages/quicktype-core/src/language/Elm/utils.ts +++ b/packages/quicktype-core/src/language/Elm/utils.ts @@ -3,12 +3,16 @@ import { allLowerWordStyle, allUpperWordStyle, combineWords, + escapeNonPrintableMapper, firstUpperWordStyle, + intToHex, isAscii, - isLetterOrUnderscore, + isLetter, isLetterOrUnderscoreOrDigit, + isPrintable, legalizeCharacters, splitIntoWords, + utf32ConcatMap, } from "../../support/Strings.js"; import { type ClassProperty, UnionType } from "../../Type/index.js"; import { nullableFromUnion } from "../../Type/TypeUtils.js"; @@ -27,10 +31,19 @@ function elmNameStyle(original: string, upper: boolean): string { upper ? allUpperWordStyle : allLowerWordStyle, allUpperWordStyle, "", - isLetterOrUnderscore, + // Elm identifiers must not start with an underscore. + isLetter, ); } +function unicodeEscape(codePoint: number): string { + return `\\u{${intToHex(codePoint, 4).toUpperCase()}}`; +} + +export const elmStringEscape = utf32ConcatMap( + escapeNonPrintableMapper(isPrintable, unicodeEscape), +); + export const upperNamingFunction = funPrefixNamer("upper", (n) => elmNameStyle(n, true), ); From e7b277b18c0534a7dc3be9611f2b3b048ec38741 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 08:42:21 -0400 Subject: [PATCH 2/3] feat(elm): default array-type to list `List` is the idiomatic Elm collection type, and with the 0.19 `Json.Encode.list` API it needs no custom encoder helper. Generating `Array` is still available via `--array-type array`. Co-Authored-By: Claude Fable 5 --- packages/quicktype-core/src/language/Elm/language.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quicktype-core/src/language/Elm/language.ts b/packages/quicktype-core/src/language/Elm/language.ts index efdbc0601a..1924a213fa 100644 --- a/packages/quicktype-core/src/language/Elm/language.ts +++ b/packages/quicktype-core/src/language/Elm/language.ts @@ -23,7 +23,7 @@ export const elmOptions = { array: false, list: true, } as const, - "array", + "list", ), // FIXME: Do this via a configurable named eventually. moduleName: new StringOption( From cba633012a66b84fed1a539d4a366b72065f6067 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 08:43:49 -0400 Subject: [PATCH 3/3] test(elm): revive Elm fixtures on Elm 0.19 and re-enable them in CI Rewrite the fixture driver for Elm 0.19: `elm.json` replaces `elm-package.json`, `Main.elm` uses `Platform.worker` and `Jdec.errorToString`, and `runner.js` uses the 0.19 embedding API (`Elm.Main.init()`). The setup command compiles a new `Warmup.elm` to pre-populate the shared ELM_HOME package cache, because elm 0.19.1 can corrupt its package registry when parallel compiles race on a cold cache; the 0.18-era `sysconfcpus` hack is gone. Every skip-list entry was re-verified against elm 0.19.1: - `identifiers.json`, `simple-identifiers.json`, `blns-object.json`, `nst-test-suite.json`, and `keywords.json` now pass (they only failed on 0.19 escape syntax, leading underscores, and shadowing, all fixed in the renderer) and are only excluded from the diff-via-schema code comparison, where schema round-tripping changes inferred names. - The recursive inputs stay skipped: Elm type aliases cannot be recursive. - `constructor.schema` and `keyword-unions.schema` stay skipped with an accurate reason: elm/json's field decoder uses the JS `in` operator, which finds inherited Object.prototype members. - `nested-intersection-union.schema` is newly skipped: the generated decoder accepts invalid union members because all class properties decode via `Jpipe.optional`. - `recursive-union-flattening.schema` (added since the fixture was disabled) is skipped for recursion. The quicktest renderer option flips to `array-type: array` to keep the non-default Array path covered now that `list` is the default. The full corpus passes locally with elm 0.19.1 (215 JSON tests including misc, 67 schema tests), so `elm,schema-elm` returns to the CI matrix; the workflow's elm 0.19.1 install step already existed. Co-Authored-By: Claude Fable 5 --- .github/workflows/test-pr.yaml | 4 +-- test/fixtures/elm/Main.elm | 27 +++++++++++------ test/fixtures/elm/Warmup.elm | 18 ++++++++++++ test/fixtures/elm/elm-package.json | 14 --------- test/fixtures/elm/elm.json | 17 +++++++++++ test/fixtures/elm/runner.js | 8 ++--- test/languages.ts | 47 ++++++++++++++++++------------ 7 files changed, 86 insertions(+), 49 deletions(-) create mode 100644 test/fixtures/elm/Warmup.elm delete mode 100644 test/fixtures/elm/elm-package.json create mode 100644 test/fixtures/elm/elm.json diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index fe79e6fc67..da9bd1061e 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -55,14 +55,12 @@ jobs: - scala3,schema-scala3 - scala3-upickle,schema-scala3-upickle - elixir,schema-elixir,graphql-elixir + - elm,schema-elm - comment-injection-treesitter,comment-injection-typescript,comment-injection-typescript-zod,comment-injection-typescript-effect-schema # Partially working # - schema-typescript # TODO Unify with typescript once fixed - # Implementation is too outdated to test in GitHub Actions - # - elm,schema-elm - # Language is too niche / obscure to test easily on ubuntu-22.04 # - pike,schema-pike diff --git a/test/fixtures/elm/Main.elm b/test/fixtures/elm/Main.elm index f821145e22..b1c7345b82 100644 --- a/test/fixtures/elm/Main.elm +++ b/test/fixtures/elm/Main.elm @@ -1,31 +1,40 @@ -port module Main exposing (..) +port module Main exposing (main) --- this is required for the ports -import Json.Decode exposing (decodeString) +import Json.Decode exposing (decodeString, errorToString) import QuickType + port fromJS : (String -> msg) -> Sub msg + + port toJS : String -> Cmd msg + type Msg = FromJS String + update : Msg -> () -> ( (), Cmd Msg ) update msg _ = case msg of FromJS str -> case decodeString QuickType.quickType str of - Ok r -> ((), toJS (QuickType.quickTypeToString r)) - Err err -> ((), toJS ("Error: " ++ err)) + Ok r -> + ( (), toJS (QuickType.quickTypeToString r) ) + + Err err -> + ( (), toJS ("Error: " ++ errorToString err) ) + subscriptions : () -> Sub Msg subscriptions _ = - fromJS (FromJS) + fromJS FromJS + -main : Program Never () Msg +main : Program () () Msg main = - Platform.program - { init = ( (), Cmd.none ) + Platform.worker + { init = \_ -> ( (), Cmd.none ) , update = update , subscriptions = subscriptions } diff --git a/test/fixtures/elm/Warmup.elm b/test/fixtures/elm/Warmup.elm new file mode 100644 index 0000000000..c0b29a36af --- /dev/null +++ b/test/fixtures/elm/Warmup.elm @@ -0,0 +1,18 @@ +module Warmup exposing (warmup) + +{-| Compiled once by the fixture's setup command so that all package +dependencies are downloaded and built into the shared ELM_HOME cache +before the per-sample compiles run in parallel. Concurrent cold-cache +downloads can corrupt elm 0.19.1's package registry. +-} + +import Dict exposing (Dict) +import Json.Decode as Jdec +import Json.Decode.Pipeline as Jpipe +import Json.Encode as Jenc + + +warmup : Jdec.Decoder ( Dict String Int, Jenc.Value ) +warmup = + Jdec.succeed (\x -> ( Dict.empty, x )) + |> Jpipe.required "x" Jdec.value diff --git a/test/fixtures/elm/elm-package.json b/test/fixtures/elm/elm-package.json deleted file mode 100644 index d688a53c96..0000000000 --- a/test/fixtures/elm/elm-package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "1.0.0", - "summary": "helpful summary of your project, less than 80 characters", - "repository": "https://github.com/user/project.git", - "license": "BSD3", - "source-directories": ["."], - "exposed-modules": [], - "dependencies": { - "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", - "elm-lang/core": "5.1.1 <= v < 6.0.0", - "elm-lang/html": "2.0.0 <= v < 3.0.0" - }, - "elm-version": "0.18.0 <= v < 0.19.0" -} diff --git a/test/fixtures/elm/elm.json b/test/fixtures/elm/elm.json new file mode 100644 index 0000000000..47fcea36e2 --- /dev/null +++ b/test/fixtures/elm/elm.json @@ -0,0 +1,17 @@ +{ + "type": "application", + "source-directories": ["."], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.1", + "elm/core": "1.0.5", + "elm/json": "1.1.3" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/test/fixtures/elm/runner.js b/test/fixtures/elm/runner.js index d1c23b799f..455b1b68e5 100644 --- a/test/fixtures/elm/runner.js +++ b/test/fixtures/elm/runner.js @@ -1,9 +1,9 @@ -const Elm = require("./elm.js"); +const { Elm } = require("./elm.js"); const fs = require("fs"); -const ports = Elm.Main.worker().ports; +const app = Elm.Main.init(); -ports.toJS.subscribe((result) => { +app.ports.toJS.subscribe((result) => { if (result.startsWith("Error: ")) { process.stderr.write(result + "\n", () => { process.exit(1); @@ -15,4 +15,4 @@ ports.toJS.subscribe((result) => { } }); -ports.fromJS.send(fs.readFileSync(process.argv[2], "utf8")); +app.ports.fromJS.send(fs.readFileSync(process.argv[2], "utf8")); diff --git a/test/languages.ts b/test/languages.ts index 3b4362efb2..69b5fecb77 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1,6 +1,5 @@ import type { LanguageName } from "quicktype-core"; -import * as process from "node:process"; // @ts-expect-error: ../dist only exists after the root package is built import type { RendererOptions } from "../dist/quicktype-core/Run"; @@ -732,16 +731,21 @@ export const CPlusPlusLanguage: Language = { export const ElmLanguage: Language = { name: "elm", base: "test/fixtures/elm", - setupCommand: "rm -rf elm-stuff/build-artifacts && elm-make --yes", - compileCommand: - process.env.CI === "true" - ? "sysconfcpus -n 1 elm-make Main.elm QuickType.elm --output elm.js" - : "elm-make Main.elm QuickType.elm --output elm.js", + // Compiling `Warmup.elm` once up front downloads and builds all package + // dependencies into the shared ELM_HOME cache; elm 0.19.1 can corrupt + // its package registry when parallel compiles race on a cold cache. + setupCommand: "rm -rf elm-stuff && elm make Warmup.elm --output=/dev/null", + compileCommand: "elm make Main.elm --output elm.js", runCommand(sample: string) { return `node ./runner.js "${sample}"`; }, diffViaSchema: true, skipDiffViaSchema: [ + "blns-object.json", + "identifiers.json", + "keywords.json", + "nst-test-suite.json", + "simple-identifiers.json", "bug863.json", "reddit.json", "github-events.json", @@ -767,21 +771,17 @@ export const ElmLanguage: Language = { features: ["enum", "union", "no-defaults"], output: "QuickType.elm", topLevel: "QuickType", + // Elm type aliases cannot be recursive, so all inputs that produce + // recursive types must be skipped. skipJSON: [ - "identifiers.json", - "simple-identifiers.json", - "blns-object.json", - "recursive.json", - "direct-recursive.json", - "bug427.json", - "bug790.json", - "list.json", - "nst-test-suite.json", - "keywords.json", // stack overflow + "recursive.json", // recursion + "direct-recursive.json", // recursion + "bug427.json", // recursion + "bug790.json", // recursion + "list.json", // recursion ], skipMiscJSON: false, skipSchema: [ - "constructor.schema", // can't handle "constructor" property "union-list.schema", // recursion "list.schema", // recursion "ref-remote.schema", // recursion @@ -789,10 +789,19 @@ export const ElmLanguage: Language = { "postman-collection.schema", // recursion "vega-lite.schema", // recursion "simple-ref.schema", // recursion - "keyword-unions.schema", // can't handle "hasOwnProperty" for some reason + "recursive-union-flattening.schema", // recursion + // elm/json's field decoder uses the JS `in` operator, which finds + // inherited Object.prototype members, so an absent "constructor" + // property decodes to the object's constructor function. + "constructor.schema", + "keyword-unions.schema", + // The generated decoder accepts invalid union members because all + // class properties decode via `Jpipe.optional`. + "nested-intersection-union.schema", ], rendererOptions: {}, - quickTestRendererOptions: [{ "array-type": "list" }], + // `list` is the default now; keep the `Array` code path covered. + quickTestRendererOptions: [{ "array-type": "array" }], sourceFiles: ["src/language/Elm/index.ts"], };