diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index fe79e6fc67..7001f80230 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 @@ -136,7 +134,7 @@ jobs: - name: Install Elm if: ${{ contains(matrix.fixture, 'elm') }} run: | - curl -L -o elm.gz https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz + curl -L -o elm.gz https://github.com/elm/compiler/releases/download/0.19.2/elm-0.19.2-linux-x64.gz gunzip elm.gz chmod +x elm sudo mv elm /usr/local/bin/ diff --git a/CLAUDE.md b/CLAUDE.md index 938400de68..7a769ca160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,9 @@ JSON to the input. `.N.json` samples and `.N.fail..json` expected-failure samples. A fail sample must make the generated program exit nonzero; which fail samples run is controlled by the language's `features` list. +- Every schema fixture test must have at least one positive (`.N.json`) and + one negative (`.N.fail..json`) test case, unless there is a very + good reason not to. - Per-language configuration — which inputs run (`skipJSON`, `includeJSON`, `skipSchema`), renderer options, and `features` — lives in `test/languages.ts`; fixtures are registered in `test/fixtures.ts`. @@ -25,7 +28,29 @@ Any change that affects generated output MUST be covered by a JSON or JSON Schema fixture test — by enabling existing inputs for the language or adding new ones. Unit tests in `test/unit/` are a complement for what fixtures cannot express (asserting that some code is *not* generated, API-level behavior, fast -local iteration) — never a substitute. +local iteration) — never a substitute. Do not add a unit test when a fixture +test will do the job: if a fixture input already exercises the behavior, a +unit test duplicating that coverage is superfluous and should not be added. + +## Known CI flakiness + +Three fixture-CI failure modes are infrastructure flakes, not test or PR bugs: + +- **scala3-upickle**: the Bloop compiler server sometimes times out after 30 + seconds at startup, and `maven-nightlies` artifact downloads sometimes fail. +- **elm**: the fixture setup (`rm -rf elm-stuff && elm make Warmup.elm`) can + race the compiler, deadlocking on `elm-stuff/*.dat` file locks. +- **cjson**: `cJSON.c` is downloaded from raw.githubusercontent.com at test + time and can hit transient SSL/connection failures. + +Two things amplify them: the fixture matrix runs with `fail-fast: true`, so +one flaky job cancels all sibling language jobs, and the `test-complete` +check only mirrors the matrix — it is never an independent failure. + +For the time being we accept these flakes; when one happens, retry the failed +jobs (`gh run rerun --failed`). A failure in one of these areas only +counts as real if it reproduces across retries or the PR actually touches +that area. ## Releasing / version bumps diff --git a/package-lock.json b/package-lock.json index 0436aecd16..347e305740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "quicktype-graphql-input": "24.0.0", "quicktype-typescript-input": "24.0.0", "readable-stream": "^4.5.2", - "stream-json": "1.8.0", + "stream-json": "3.5.0", "string-to-stream": "^3.0.1", "wordwrap": "^1.0.0" }, @@ -44,7 +44,6 @@ "@types/node": "~20.19.0", "@types/semver": "^7.5.0", "@types/shelljs": "^0.10.0", - "@types/stream-json": "^1.7.3", "@types/urijs": "^1.19.26", "@types/wordwrap": "^1.0.3", "ajv": "^8.20.0", @@ -1993,23 +1992,6 @@ "fast-glob": "^3.3.2" } }, - "node_modules/@types/stream-chain": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/stream-json": { - "version": "1.7.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/stream-chain": "*" - } - }, "node_modules/@types/unicode-properties": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/unicode-properties/-/unicode-properties-1.3.2.tgz", @@ -8371,14 +8353,30 @@ } }, "node_modules/stream-chain": { - "version": "2.2.5", - "license": "BSD-3-Clause" + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-4.2.5.tgz", + "integrity": "sha512-Wtyq3bNE3ggLR0v2vftqvuhltym3WbZAkZpfIrkr5F/6vpeUmWmwTgXa16zD87gpahwJ/Qulq3zVfUlgIc0J2A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/uhop" + } }, "node_modules/stream-json": { - "version": "1.8.0", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-3.5.0.tgz", + "integrity": "sha512-dobB7zipGW8o11PvdRljQSWuyMxifADLvoHeA4elwNWOTbZo6+BlNa+P6aCq7Y9jRiWTy2Ucu2xSv0Y2/T+/kQ==", "license": "BSD-3-Clause", "dependencies": { - "stream-chain": "^2.2.5" + "stream-chain": "^4.2.5" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/uhop" } }, "node_modules/stream-read-all": { diff --git a/package.json b/package.json index 9324aeb5e9..5de5e45354 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "dist/index.d.ts", "repository": "https://github.com/glideapps/quicktype", "engines": { - "node": ">=20.0.0" + "node": ">=20.19.0" }, "scripts": { "pub": "script/publish-npm.sh && script/publish-vscode.sh", @@ -48,7 +48,7 @@ "quicktype-graphql-input": "24.0.0", "quicktype-typescript-input": "24.0.0", "readable-stream": "^4.5.2", - "stream-json": "1.8.0", + "stream-json": "3.5.0", "string-to-stream": "^3.0.1", "wordwrap": "^1.0.0" }, @@ -61,7 +61,6 @@ "@types/node": "~20.19.0", "@types/semver": "^7.5.0", "@types/shelljs": "^0.10.0", - "@types/stream-json": "^1.7.3", "@types/urijs": "^1.19.26", "@types/wordwrap": "^1.0.3", "ajv": "^8.20.0", diff --git a/packages/quicktype-core/src/ConvenienceRenderer.ts b/packages/quicktype-core/src/ConvenienceRenderer.ts index de5901e6e2..af8ff16031 100644 --- a/packages/quicktype-core/src/ConvenienceRenderer.ts +++ b/packages/quicktype-core/src/ConvenienceRenderer.ts @@ -995,10 +995,7 @@ export abstract class ConvenienceRenderer extends Renderer { f: (name: Name, jsonName: string, position: ForEachPosition) => void, ): void { const caseNames = defined(this._caseNamesStoreView).get(e); - const sortedCaseNames = mapSortBy(caseNames, (n) => - defined(this.names.get(n)), - ); - this.forEachWithBlankLines(sortedCaseNames, blankLocations, f); + this.forEachWithBlankLines(caseNames, blankLocations, f); } protected forEachTransformation( diff --git a/packages/quicktype-core/src/GraphRewriting.ts b/packages/quicktype-core/src/GraphRewriting.ts index 69d1781ffb..7e0ece821f 100644 --- a/packages/quicktype-core/src/GraphRewriting.ts +++ b/packages/quicktype-core/src/GraphRewriting.ts @@ -189,8 +189,14 @@ export class TypeReconstituter { } public getUniqueArrayType(): void { - this.registerAndAddAttributes( - this.builderForNewType().getUniqueArrayType(this._forwardingRef), + // The attributes must be passed at creation, not added + // afterwards with `addAttributes`, which would assert on + // identity attributes such as minItems/maxItems. + this.register( + this.builderForNewType().getUniqueArrayType( + this._typeAttributes, + this._forwardingRef, + ), ); } diff --git a/packages/quicktype-core/src/Type/TypeBuilder.ts b/packages/quicktype-core/src/Type/TypeBuilder.ts index bb2edba208..d358c6175b 100644 --- a/packages/quicktype-core/src/Type/TypeBuilder.ts +++ b/packages/quicktype-core/src/Type/TypeBuilder.ts @@ -479,11 +479,14 @@ export class TypeBuilder { this.registerType(type); } - public getUniqueArrayType(forwardingRef?: TypeRef): TypeRef { + public getUniqueArrayType( + attributes?: TypeAttributes, + forwardingRef?: TypeRef, + ): TypeRef { return this.addType( forwardingRef, (tr) => new ArrayType(tr, this.typeGraph, undefined), - undefined, + attributes, ); } diff --git a/packages/quicktype-core/src/attributes/Constraints.ts b/packages/quicktype-core/src/attributes/Constraints.ts index 4ef6561866..f74be89044 100644 --- a/packages/quicktype-core/src/attributes/Constraints.ts +++ b/packages/quicktype-core/src/attributes/Constraints.ts @@ -97,7 +97,7 @@ export class MinMaxConstraintTypeAttributeKind extends TypeAttributeKind "maxLength", ); +export const minMaxItemsTypeAttributeKind: TypeAttributeKind = + new MinMaxConstraintTypeAttributeKind( + "minMaxItems", + new Set(["array"]), + "minItems", + "maxItems", + ); + function producer( schema: JSONSchema, minProperty: string, @@ -179,6 +187,20 @@ export function minMaxLengthAttributeProducer( }; } +export function minMaxItemsAttributeProducer( + schema: JSONSchema, + _ref: Ref, + types: Set, +): JSONSchemaAttributes | undefined { + if (!types.has("array")) return undefined; + + const maybeMinMaxItems = producer(schema, "minItems", "maxItems"); + if (maybeMinMaxItems === undefined) return undefined; + return { + forArray: minMaxItemsTypeAttributeKind.makeAttributes(maybeMinMaxItems), + }; +} + export function minMaxValueForType(t: Type): MinMaxConstraint | undefined { return minMaxTypeAttributeKind.tryGetInAttributes(t.getAttributes()); } @@ -187,6 +209,10 @@ export function minMaxLengthForType(t: Type): MinMaxConstraint | undefined { return minMaxLengthTypeAttributeKind.tryGetInAttributes(t.getAttributes()); } +export function minMaxItemsForType(t: Type): MinMaxConstraint | undefined { + return minMaxItemsTypeAttributeKind.tryGetInAttributes(t.getAttributes()); +} + export class PatternTypeAttributeKind extends TypeAttributeKind { public constructor() { super("pattern"); diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index d3a0e4feef..fe9efcbd8e 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -25,6 +25,7 @@ import URI from "urijs"; import { accessorNamesAttributeProducer } from "../attributes/AccessorNames.js"; import { minMaxAttributeProducer, + minMaxItemsAttributeProducer, minMaxLengthAttributeProducer, patternAttributeProducer, } from "../attributes/Constraints.js"; @@ -306,7 +307,7 @@ export class Ref { public get definitionName(): string | undefined { const pe = arrayGetFromEnd(this.path, 2); if (pe === undefined) return undefined; - if (keyOrIndex(pe) === "definitions") + if (keyOrIndex(pe) === "definitions" || keyOrIndex(pe) === "$defs") return keyOrIndex(defined(arrayLast(this.path))); return undefined; } @@ -649,6 +650,7 @@ const schemaTypes = Object.getOwnPropertyNames( ) as JSONSchemaType[]; export interface JSONSchemaAttributes { + forArray?: TypeAttributes; forCases?: TypeAttributes[]; forNumber?: TypeAttributes; forObject?: TypeAttributes; @@ -783,7 +785,7 @@ class Resolver { return [schema, result[1]]; } - return schemaFetchError(base, virtualRef.address); + return schemaFetchError(base, virtualRef.toString()); } public async resolveTopLevelRef(ref: Ref): Promise<[JSONSchema, Location]> { @@ -1031,7 +1033,9 @@ async function addTypesInSchema( return typeBuilder.getPrimitiveType(kind, attributes); } - async function makeArrayType(): Promise { + async function makeArrayType( + arrayAttributes: TypeAttributes, + ): Promise { const singularAttributes = singularizeTypeNames(typeAttributes); const items = schema.items; // JSON Schema 2020-12 renamed the array (tuple) form of `items` to @@ -1097,7 +1101,7 @@ async function addTypesInSchema( } typeBuilder.addAttributes(itemType, singularAttributes); - return typeBuilder.getArrayType(emptyTypeAttributes, itemType); + return typeBuilder.getArrayType(arrayAttributes, itemType); } async function makeObjectType(): Promise { @@ -1139,6 +1143,14 @@ async function addTypesInSchema( additionalProperties = schema.patternProperties[".*"]; } + // Handle unevaluatedProperties if additionalProperties is not defined + if ( + additionalProperties === undefined && + schema.unevaluatedProperties !== undefined + ) { + additionalProperties = schema.unevaluatedProperties; + } + const objectAttributes = combineTypeAttributes( "union", inferredAttributes, @@ -1314,7 +1326,10 @@ async function addTypesInSchema( } if (includeArray) { - unionTypes.push(await makeArrayType()); + const arrayAttributes = combineProducedAttributes( + ({ forArray }) => forArray, + ); + unionTypes.push(await makeArrayType(arrayAttributes)); } if (includeObject) { @@ -1549,6 +1564,7 @@ export class JSONSchemaInput implements Input { uriSchemaAttributesProducer, minMaxAttributeProducer, minMaxLengthAttributeProducer, + minMaxItemsAttributeProducer, patternAttributeProducer, ].concat(additionalAttributeProducers); } diff --git a/packages/quicktype-core/src/input/io/NodeIO.ts b/packages/quicktype-core/src/input/io/NodeIO.ts index a5df74f00f..137e8ecf71 100644 --- a/packages/quicktype-core/src/input/io/NodeIO.ts +++ b/packages/quicktype-core/src/input/io/NodeIO.ts @@ -107,6 +107,11 @@ export async function readableFromFileOrURL( const response = await globalThis.fetch(fileOrURL, { headers: parseHeaders(httpHeaders), }); + if (!response.ok) { + throw new Error( + `HTTP ${response.status} ${response.statusText}`, + ); + } return readableFromResponseBody(defined(response.body)); } diff --git a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts index c750529c98..442978d9e6 100644 --- a/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts +++ b/packages/quicktype-core/src/language/CJSON/CJSONRenderer.ts @@ -428,6 +428,7 @@ export class CJSONRenderer extends ConvenienceRenderer { const combinedName = allUpperWordStyle( this.sourcelikeToString(enumName), ); + let isFirst = true; this.forEachEnumCase(enumType, "none", (name, jsonName) => { if (enumValues !== undefined) { const [enumValue] = getAccessorName( @@ -444,11 +445,22 @@ export class CJSONRenderer extends ConvenienceRenderer { ",", ); } else { - this.emitLine(combinedName, "_", name, ","); + this.emitLine( + combinedName, + "_", + name, + isFirst ? " = 1," : ",", + ); } } else { - this.emitLine(combinedName, "_", name, ","); + this.emitLine( + combinedName, + "_", + name, + isFirst ? " = 1," : ",", + ); } + isFirst = false; }); }, "", diff --git a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts index 289d2652ae..64f530a23c 100644 --- a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts +++ b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts @@ -503,21 +503,13 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { "", ]); - if (this._options.typeSourceStyle) { - this.forEachTopLevel("none", (_, topLevelName) => { - this.emitLine( - "// ", - topLevelName, - " data = nlohmann::json::parse(jsonString);", - ); - }); - } else { + this.forEachTopLevel("none", (_, topLevelName) => { this.emitLine( "// ", - basename, + topLevelName, " data = nlohmann::json::parse(jsonString);", ); - } + }); if (this._options.wstring) { this.emitLine("//"); @@ -1373,6 +1365,11 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { ], false, () => { + if (c.getProperties().size === 0) { + this.emitLine("(void)j;"); + this.emitLine("(void)x;"); + } + this.forEachClassProperty(c, "none", (name, json, p) => { const [, , setterName] = defined( this._gettersAndSettersForPropertyName.get(name), @@ -1562,6 +1559,10 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { false, () => { this.emitLine("j = json::object();"); + if (c.getProperties().size === 0) { + this.emitLine("(void)x;"); + } + this.forEachClassProperty(c, "none", (name, json, p) => { const propType = p.type; cppType = this.cppType( @@ -2144,8 +2145,14 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { ], false, () => { + // A JSON null must become an *empty* + // optional. Only `optType()` guarantees + // that: the factory would wrap a + // default-constructed T (std::make_optional + // and std::make_shared both do), turning + // null into 0/""/{} on round-trip. this.emitLine( - `if (j.is_null()) return ${factory}(); else return ${factory}(j.get());`, + `if (j.is_null()) return ${optType}(); else return ${factory}(j.get());`, ); }, ); @@ -3181,6 +3188,7 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { this.emitHelper(); this.startFile("Generators.hpp", true); + this._generatedFiles.add("Generators.hpp"); this._allTypeNames.forEach((t) => { this.emitInclude(false, [t, ".hpp"]); @@ -3397,6 +3405,28 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { ); this.superThis.ensureBlankLine(); + if (this.superThis.haveOptionalProperties) { + this.superThis.emitLine( + "template", + ); + this.superThis.emitBlock( + [ + "static toType convert(tag<", + this.superThis._optionalType, + " >, tag<", + this.superThis._optionalType, + " >, fromType opt)", + ], + false, + () => { + this.superThis.emitLine( + "if (!opt) return toType(); else return toType(Utf16_Utf8::convert(*opt));", + ); + }, + ); + this.superThis.ensureBlankLine(); + } + this.superThis.emitLine("template"); this.superThis.emitBlock( [ diff --git a/packages/quicktype-core/src/language/CPlusPlus/language.ts b/packages/quicktype-core/src/language/CPlusPlus/language.ts index e614e7ec9e..cca4dd10be 100644 --- a/packages/quicktype-core/src/language/CPlusPlus/language.ts +++ b/packages/quicktype-core/src/language/CPlusPlus/language.ts @@ -104,7 +104,7 @@ export const cPlusPlusOptions = { boost: new BooleanOption( "boost", "Require a dependency on boost. Without boost, C++17 is required", - true, + false, ), hideNullOptional: new BooleanOption( "hide-null-optional", diff --git a/packages/quicktype-core/src/language/CSharp/CSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/CSharpRenderer.ts index 3e331a310b..482cb14f71 100644 --- a/packages/quicktype-core/src/language/CSharp/CSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/CSharpRenderer.ts @@ -15,11 +15,11 @@ import { type Sourcelike, maybeAnnotated } from "../../Source.js"; import { assert } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; import { followTargetType } from "../../Transformers.js"; -import type { - ClassProperty, - ClassType, - EnumType, - Type, +import { + type ClassProperty, + type ClassType, + type EnumType, + type Type, UnionType, } from "../../Type/index.js"; import { @@ -187,6 +187,18 @@ export class CSharpRenderer extends ConvenienceRenderer { withIssues = false, ): Sourcelike { t = followTargetType(t); + // A nullable union already renders with its own "?" through + // csType's union case; unwrap it so the annotation is applied + // exactly once. Without this, an optional property whose type + // is e.g. `string | null` would render as `string??` at C# 8 + // (and a nullable value-type union as `long??` at any version). + if (t instanceof UnionType) { + const nullable = nullableFromUnion(t); + if (nullable !== null) { + t = followTargetType(nullable); + } + } + const csType = this.csType(t, follow, withIssues); if (isValueType(t) || this._csOptions.version >= 8) { return [csType, "?"]; diff --git a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts index 1993f6e135..4fbd16c23c 100644 --- a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts @@ -197,15 +197,18 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { this.emitLine("#pragma warning restore CS8618"); this.emitLine("#pragma warning restore CS8601"); + this.emitLine("#pragma warning restore CS8602"); this.emitLine("#pragma warning restore CS8603"); + this.emitLine("#pragma warning restore CS8604"); + this.emitLine("#pragma warning restore CS8625"); this.emitLine("#pragma warning restore CS8765"); } protected emitDefaultLeadingComments(): void { - if (!this._needHelpers) return; - this.emitLine("// "); this.emitLine("//"); + + if (!this._needHelpers) return; this.emitLine( "// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do", this.topLevels.size === 1 ? "" : " one of these", @@ -239,7 +242,13 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { this.emitLine("#nullable enable"); this.emitLine("#pragma warning disable CS8618"); this.emitLine("#pragma warning disable CS8601"); + // CS8602/CS8604/CS8625: the emitted constraint-check and + // string-transformer helpers dereference and pass around + // Deserialize() results, which are nullable under NRT. + this.emitLine("#pragma warning disable CS8602"); this.emitLine("#pragma warning disable CS8603"); + this.emitLine("#pragma warning disable CS8604"); + this.emitLine("#pragma warning disable CS8625"); this.emitLine("#pragma warning disable CS8765"); } } diff --git a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts index e64a915dcb..96bbf0edc0 100644 --- a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts @@ -196,14 +196,15 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitLine("#pragma warning restore CS8618"); this.emitLine("#pragma warning restore CS8601"); + this.emitLine("#pragma warning restore CS8602"); this.emitLine("#pragma warning restore CS8603"); } protected emitDefaultLeadingComments(): void { - if (!this._needHelpers) return; - this.emitLine("// "); this.emitLine("//"); + + if (!this._needHelpers) return; this.emitLine( "// To parse this JSON data, add NuGet 'System.Text.Json' then do", this.topLevels.size === 1 ? "" : " one of these", @@ -237,6 +238,9 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitLine("#nullable enable"); this.emitLine("#pragma warning disable CS8618"); this.emitLine("#pragma warning disable CS8601"); + // CS8602: the emitted constraint-check converters dereference + // Deserialize() results, which are nullable under NRT. + this.emitLine("#pragma warning disable CS8602"); this.emitLine("#pragma warning disable CS8603"); } @@ -371,8 +375,11 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitLine("case JsonTokenType.", tokenType, ":"); } - private emitThrow(message: Sourcelike): void { - this.emitLine("throw new Exception(", message, ");"); + private emitThrow( + exceptionType: "JsonException" | "NotSupportedException", + message: Sourcelike, + ): void { + this.emitLine("throw new ", exceptionType, "(", message, ");"); } private deserializeTypeCode(typeName: Sourcelike): Sourcelike { @@ -508,8 +515,11 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { } } - this.emitLine("new DateOnlyConverter(),"); - this.emitLine("new TimeOnlyConverter(),"); + if (this._options.dateTimeOnlyConverters) { + this.emitLine("new DateOnlyConverter(),"); + this.emitLine("new TimeOnlyConverter(),"); + } + this.emitLine("IsoDateTimeOffsetConverter.Singleton"); // this.emitLine("new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }"); }); @@ -1235,7 +1245,7 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { (v) => this.emitLine("return ", v, ";"), ); if (!allHandled) { - this.emitThrow([ + this.emitThrow("JsonException", [ '"Cannot unmarshal type ', csType, '"', @@ -1262,7 +1272,7 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { () => this.emitLine("return;"), ); if (!allHandled) { - this.emitThrow([ + this.emitThrow("NotSupportedException", [ '"Cannot marshal type ', csType, '"', @@ -1301,7 +1311,16 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.forEachTransformation("leading-and-interposing", (n, t) => this.emitTransformation(n, t), ); - this.emitMultiline(` + if (this._options.dateTimeOnlyConverters) { + this.emitDateTimeOnlyConverters(); + } + + this.emitIsoDateTimeOffsetConverter(); + } + } + + private emitDateTimeOnlyConverters(): void { + this.emitMultiline(` public class DateOnlyConverter : JsonConverter { private readonly string serializationFormat; @@ -1341,9 +1360,12 @@ public class TimeOnlyConverter : JsonConverter public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString(serializationFormat)); -} +}`); + } -internal class IsoDateTimeOffsetConverter : JsonConverter + private emitIsoDateTimeOffsetConverter(): void { + this.ensureBlankLine(); + this.emitMultiline(`internal class IsoDateTimeOffsetConverter : JsonConverter { public override bool CanConvert(Type t) => t == typeof(DateTimeOffset); @@ -1411,7 +1433,6 @@ internal class IsoDateTimeOffsetConverter : JsonConverter public static readonly IsoDateTimeOffsetConverter Singleton = new IsoDateTimeOffsetConverter(); }`); - } } protected needNamespace(): boolean { diff --git a/packages/quicktype-core/src/language/CSharp/language.ts b/packages/quicktype-core/src/language/CSharp/language.ts index b7447d7329..a894415acb 100644 --- a/packages/quicktype-core/src/language/CSharp/language.ts +++ b/packages/quicktype-core/src/language/CSharp/language.ts @@ -35,7 +35,7 @@ export const cSharpOptions = { NewtonSoft: "NewtonSoft", SystemTextJson: "SystemTextJson", } as const, - "NewtonSoft", + "SystemTextJson", ), useList: new EnumOption( "array-type", @@ -71,7 +71,7 @@ export const cSharpOptions = { "6": 6, "8": 8, } as const, - "6", + "8", "secondary", ), virtual: new BooleanOption("virtual", "Generate virtual properties", false), @@ -133,7 +133,15 @@ export const cSharpOptions = { export const newtonsoftCSharpOptions = { ...cSharpOptions }; -export const systemTextJsonCSharpOptions = { ...cSharpOptions }; +export const systemTextJsonCSharpOptions = { + ...cSharpOptions, + dateTimeOnlyConverters: new BooleanOption( + "dateonly-timeonly-converters", + "Emit DateOnly/TimeOnly converters (requires .NET 6 or later)", + true, + "secondary", + ), +}; export const cSharpLanguageConfig = { displayName: "C#", @@ -148,8 +156,8 @@ export class CSharpTargetLanguage extends TargetLanguage< super(cSharpLanguageConfig); } - public getOptions(): typeof cSharpOptions { - return cSharpOptions; + public getOptions(): typeof systemTextJsonCSharpOptions { + return systemTextJsonCSharpOptions; } public get stringTypeMapping(): StringTypeMapping { diff --git a/packages/quicktype-core/src/language/Dart/DartRenderer.ts b/packages/quicktype-core/src/language/Dart/DartRenderer.ts index f11f77f6f3..6a9be9fa39 100644 --- a/packages/quicktype-core/src/language/Dart/DartRenderer.ts +++ b/packages/quicktype-core/src/language/Dart/DartRenderer.ts @@ -117,12 +117,12 @@ export class DartRenderer extends ConvenienceRenderer { const encoder = new DependencyName( propertyNamingFunction, name.order, - (lookup) => `${lookup(name)}_${this.toJson}`, + (lookup) => `${lookup(name)}_toJson`, ); const decoder = new DependencyName( propertyNamingFunction, name.order, - (lookup) => `${lookup(name)}_${this.fromJson}`, + (lookup) => `${lookup(name)}_fromJson`, ); this._topLevelDependents.set(name, { encoder, decoder }); return [encoder, decoder]; @@ -266,9 +266,7 @@ export class DartRenderer extends ConvenienceRenderer { ): Sourcelike { const nullable = forceNullable || - (this._options.nullSafety && - t.isNullable && - !this._options.requiredProperties); + (t.isNullable && !this._options.requiredProperties); const withNullable = (s: Sourcelike): Sourcelike => nullable ? [s, "?"] : s; return matchType( @@ -321,14 +319,10 @@ export class DartRenderer extends ConvenienceRenderer { list: Sourcelike, mapper: Sourcelike, ): Sourcelike { - if ( - this._options.nullSafety && - isNullable && - !this._options.requiredProperties - ) { + if (isNullable && !this._options.requiredProperties) { return [ list, - " == null ? [] : ", + " == null ? null : ", "List<", itemType, ">.from(", @@ -356,11 +350,7 @@ export class DartRenderer extends ConvenienceRenderer { map: Sourcelike, valueMapper: Sourcelike, ): Sourcelike { - if ( - this._options.nullSafety && - isNullable && - !this._options.requiredProperties - ) { + if (isNullable && !this._options.requiredProperties) { return [ "Map.from(", map, @@ -388,11 +378,7 @@ export class DartRenderer extends ConvenienceRenderer { classType: ClassType, dynamic: Sourcelike, ): Sourcelike { - if ( - this._options.nullSafety && - isNullable && - !this._options.requiredProperties - ) { + if (isNullable && !this._options.requiredProperties) { return [ dynamic, " == null ? null : ", @@ -431,10 +417,7 @@ export class DartRenderer extends ConvenienceRenderer { (_nullType) => dynamic, // FIXME: check null (_boolType) => dynamic, (_integerType) => dynamic, - (_doubleType) => [ - dynamic, - this._options.nullSafety ? "?.toDouble()" : ".toDouble()", - ], + (_doubleType) => [dynamic, "?.toDouble()"], (_stringType) => dynamic, (arrayType) => this.mapList( @@ -469,8 +452,7 @@ export class DartRenderer extends ConvenienceRenderer { defined(this._enumValues.get(enumType)), ".map[", dynamic, - this._options.nullSafety && - (!isNullable || this._options.requiredProperties) + !isNullable || this._options.requiredProperties ? "]!" : "]", ]; @@ -493,8 +475,7 @@ export class DartRenderer extends ConvenienceRenderer { case "date": if ( (transformedStringType.isNullable || isNullable) && - !this._options.requiredProperties && - this._options.nullSafety + !this._options.requiredProperties ) { return [ dynamic, @@ -544,7 +525,6 @@ export class DartRenderer extends ConvenienceRenderer { ), (_classType) => { if ( - this._options.nullSafety && (_classType.isNullable || isNullable) && !this._options.requiredProperties ) { @@ -588,7 +568,6 @@ export class DartRenderer extends ConvenienceRenderer { switch (transformedStringType.kind) { case "date-time": if ( - this._options.nullSafety && !this._options.requiredProperties && (transformedStringType.isNullable || isNullable) ) { @@ -598,7 +577,6 @@ export class DartRenderer extends ConvenienceRenderer { return [dynamic, ".toIso8601String()"]; case "date": if ( - this._options.nullSafety && !this._options.requiredProperties && (transformedStringType.isNullable || isNullable) ) { @@ -642,8 +620,8 @@ export class DartRenderer extends ConvenienceRenderer { this.forEachClassProperty(c, "none", (name, _, prop) => { const required = this._options.requiredProperties || - (this._options.nullSafety && - (!prop.type.isNullable || !prop.isOptional)); + !prop.type.isNullable || + !prop.isOptional; this.emitLine(required ? "required " : "", "this.", name, ","); }); }); @@ -869,9 +847,8 @@ export class DartRenderer extends ConvenienceRenderer { const required = this._options.requiredProperties || - (this._options.nullSafety && - (!prop.type.isNullable || - !prop.isOptional)); + !prop.type.isNullable || + !prop.isOptional; if (this._options.useJsonAnnotation) { this.classPropertyCounter++; this.emitLine( diff --git a/packages/quicktype-core/src/language/Dart/language.ts b/packages/quicktype-core/src/language/Dart/language.ts index f32e85868e..167b061d71 100644 --- a/packages/quicktype-core/src/language/Dart/language.ts +++ b/packages/quicktype-core/src/language/Dart/language.ts @@ -15,7 +15,6 @@ import type { LanguageName, RendererOptions } from "../../types.js"; import { DartRenderer } from "./DartRenderer.js"; export const dartOptions = { - nullSafety: new BooleanOption("null-safety", "Null Safety", true), justTypes: new BooleanOption("just-types", "Types only", false), codersInClass: new BooleanOption( "coders-in-class", @@ -36,7 +35,7 @@ export const dartOptions = { finalProperties: new BooleanOption( "final-props", "Make all properties final", - false, + true, ), generateCopyWith: new BooleanOption( "copy-with", 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/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( 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), ); diff --git a/packages/quicktype-core/src/language/Haskell/language.ts b/packages/quicktype-core/src/language/Haskell/language.ts index 422adbd528..6d764ae766 100644 --- a/packages/quicktype-core/src/language/Haskell/language.ts +++ b/packages/quicktype-core/src/language/Haskell/language.ts @@ -19,7 +19,7 @@ export const haskellOptions = { array: false, list: true, } as const, - "array", + "list", ), moduleName: new StringOption( "module", diff --git a/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts b/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts index 673e44ad1f..15c24bebed 100644 --- a/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Java/JavaJacksonRenderer.ts @@ -206,11 +206,16 @@ export class JacksonRenderer extends JavaRenderer { const { fieldName } = this.unionField(u, t); const rendered = this.javaTypeWithoutGenerics(true, t); if (this._options.useList && t instanceof ArrayType) { + // The TypeReference must carry the full generic type: + // a raw `TypeReference` would make Jackson accept + // any element type, so schema-invalid inputs (which the + // expected-failure fixtures rely on rejecting) would + // deserialize successfully. this.emitLine( "value.", fieldName, " = jsonParser.readValueAs(new TypeReference<", - rendered, + this.javaType(true, t), ">() {});", ); } else if ( diff --git a/packages/quicktype-core/src/language/Java/language.ts b/packages/quicktype-core/src/language/Java/language.ts index fac9e16b87..87d5166219 100644 --- a/packages/quicktype-core/src/language/Java/language.ts +++ b/packages/quicktype-core/src/language/Java/language.ts @@ -22,7 +22,7 @@ export const javaOptions = { "array-type", "Use T[] or List", { array: false, list: true } as const, - "array", + "list", ), justTypes: new BooleanOption("just-types", "Plain types only", false), dateTimeProvider: new EnumOption( diff --git a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts index c14a44efdb..def7a00ee1 100644 --- a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts +++ b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts @@ -473,7 +473,8 @@ function o(props${anyArrayAnnotation}, additional${anyAnnotation}) { } function m(additional${anyAnnotation}) { - return { props: [], additional }; + const props${anyArrayAnnotation} = []; + return { props, additional }; } function r(name${stringAnnotation}) { diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index 28adca237c..528d00dd6b 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -33,7 +33,7 @@ export const kotlinOptions = { klaxon: "Klaxon", kotlinx: "KotlinX", } as const, - "klaxon", + "jackson", ), acronymStyle: acronymOption(AcronymStyleOptions.Pascal), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), diff --git a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts index 20d5fc5aa8..98b44f4db1 100644 --- a/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts +++ b/packages/quicktype-core/src/language/Python/JSONPythonRenderer.ts @@ -22,7 +22,7 @@ import { UnionMemberMatchTransformer, transformationForType, } from "../../Transformers.js"; -import type { ClassType, Type } from "../../Type/index.js"; +import type { ClassType, Type, UnionType } from "../../Type/index.js"; import { matchType } from "../../Type/TypeUtils.js"; import { PythonRenderer } from "./PythonRenderer.js"; @@ -785,6 +785,21 @@ export class JSONPythonRenderer extends PythonRenderer { return panic(`Transformer ${xfer.kind} is not supported`); } + private unionMembers(unionType: UnionType): Type[] { + // from_float also accepts integers, so from_int must be tried first. + const members = Array.from(unionType.members); + const integerIndex = members.findIndex((m) => m.kind === "integer"); + const doubleIndex = members.findIndex((m) => m.kind === "double"); + + if (doubleIndex >= 0 && integerIndex > doubleIndex) { + const integerType = defined(members[integerIndex]); + members.splice(integerIndex, 1); + members.splice(doubleIndex, 0, integerType); + } + + return members; + } + // Returns the code to deserialize `value` as type `t`. If `t` has // an associated transformer, the code for that transformer is // returned. @@ -837,7 +852,7 @@ export class JSONPythonRenderer extends PythonRenderer { }), (unionType) => { // FIXME: handle via transformers - const deserializers = Array.from(unionType.members).map( + const deserializers = this.unionMembers(unionType).map( (m) => makeLambda(this.deserializer(identity, m)).source, ); return compose(value, (v) => [ @@ -929,7 +944,7 @@ export class JSONPythonRenderer extends PythonRenderer { ")", ]), (unionType) => { - const serializers = Array.from(unionType.members).map( + const serializers = this.unionMembers(unionType).map( (m) => makeLambda(this.serializer(identity, m)).source, ); return compose(value, (v) => [ diff --git a/packages/quicktype-core/src/language/Rust/RustRenderer.ts b/packages/quicktype-core/src/language/Rust/RustRenderer.ts index c342f0141d..eda0ace698 100644 --- a/packages/quicktype-core/src/language/Rust/RustRenderer.ts +++ b/packages/quicktype-core/src/language/Rust/RustRenderer.ts @@ -5,6 +5,7 @@ import { anyTypeIssueAnnotation, nullTypeIssueAnnotation, } from "../../Annotation.js"; +import { minMaxValueForType } from "../../attributes/Constraints.js"; import { ConvenienceRenderer, type ForbiddenWordsInfo, @@ -28,7 +29,7 @@ import { } from "../../Type/TypeUtils.js"; import { keywords } from "./constants.js"; -import type { rustOptions } from "./language.js"; +import { IntegerType, type rustOptions } from "./language.js"; import { Density, type NamingStyleKey, @@ -96,6 +97,28 @@ export class RustRenderer extends ConvenienceRenderer { return "/// "; } + private getIntegerType(integerType: Type): string { + switch (this._options.integerType) { + case IntegerType.ForceI32: + return "i32"; + case IntegerType.ForceI64: + return "i64"; + default: { + // Conservative: use i32 only when the schema bounds the + // integer on *both* sides and both bounds fit in i32. A + // one-sided bound (e.g. only `"minimum": 0`) leaves the + // other side unbounded, so it must stay i64. + const minMax = minMaxValueForType(integerType); + if (minMax === undefined) return "i64"; + const [min, max] = minMax; + if (min === undefined || max === undefined) return "i64"; + const i32Min = -2147483648; + const i32Max = 2147483647; + return min >= i32Min && max <= i32Max ? "i32" : "i64"; + } + } + } + private nullableRustType(t: Type, withIssues: boolean): Sourcelike { return ["Option<", this.breakCycle(t, withIssues), ">"]; } @@ -121,7 +144,7 @@ export class RustRenderer extends ConvenienceRenderer { "Option", ), (_boolType) => "bool", - (_integerType) => "i64", + (integerType) => this.getIntegerType(integerType), (_doubleType) => "f64", (_stringType) => "String", (arrayType) => [ @@ -376,11 +399,7 @@ export class RustRenderer extends ConvenienceRenderer { } this.ensureBlankLine(); - if (this._options.edition2018) { - this.emitLine("use serde::{Serialize, Deserialize};"); - } else { - this.emitLine("extern crate serde_derive;"); - } + this.emitLine("use serde::{Serialize, Deserialize};"); if (this.haveMaps) { this.emitLine("use std::collections::HashMap;"); diff --git a/packages/quicktype-core/src/language/Rust/language.ts b/packages/quicktype-core/src/language/Rust/language.ts index 29030a4994..ce25cf9408 100644 --- a/packages/quicktype-core/src/language/Rust/language.ts +++ b/packages/quicktype-core/src/language/Rust/language.ts @@ -4,12 +4,23 @@ import { EnumOption, getOptionValues, } from "../../RendererOptions/index.js"; +import { + INT32_RANGE, + INT64_RANGE, + type IntegerRange, +} from "../../support/IntegerRange.js"; import { TargetLanguage } from "../../TargetLanguage.js"; import type { LanguageName, RendererOptions } from "../../types.js"; import { RustRenderer } from "./RustRenderer.js"; import { Density, Visibility } from "./utils.js"; +export enum IntegerType { + Conservative = "conservative", + ForceI32 = "force-i32", + ForceI64 = "force-i64", +} + export const rustOptions = { density: new EnumOption( "density", @@ -28,10 +39,20 @@ export const rustOptions = { crate: Visibility.Crate, public: Visibility.Public, } as const, - "private", + "public", ), - deriveDebug: new BooleanOption("derive-debug", "Derive Debug impl", false), - deriveClone: new BooleanOption("derive-clone", "Derive Clone impl", false), + integerType: new EnumOption( + "integer-type", + "Integer type inference", + { + conservative: IntegerType.Conservative, + "force-i32": IntegerType.ForceI32, + "force-i64": IntegerType.ForceI64, + } as const, + "conservative", + ), + deriveDebug: new BooleanOption("derive-debug", "Derive Debug impl", true), + deriveClone: new BooleanOption("derive-clone", "Derive Clone impl", true), derivePartialEq: new BooleanOption( "derive-partial-eq", "Derive PartialEq impl", @@ -42,7 +63,6 @@ export const rustOptions = { "Skip serializing empty Option fields", false, ), - edition2018: new BooleanOption("edition-2018", "Edition 2018", true), leadingComments: new BooleanOption( "leading-comments", "Leading Comments", @@ -67,6 +87,27 @@ export class RustTargetLanguage extends TargetLanguage< return rustOptions; } + /** + * The range of whole numbers the generated integer type can + * represent. With `integer-type: force-i32` every integer renders + * as `i32`, so whole numbers in input JSON outside the i32 range + * must be inferred as `double`. `conservative` only narrows to + * `i32` when schema bounds prove it fits, so it keeps the i64 + * range. + */ + public getSupportedIntegerRange( + rendererOptions: Record = {}, + ): IntegerRange | null { + if ( + rustOptions.integerType.getValue(rendererOptions) === + IntegerType.ForceI32 + ) { + return INT32_RANGE; + } + + return INT64_RANGE; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/language/Swift/language.ts b/packages/quicktype-core/src/language/Swift/language.ts index 1e6a53b493..7e9fecf155 100644 --- a/packages/quicktype-core/src/language/Swift/language.ts +++ b/packages/quicktype-core/src/language/Swift/language.ts @@ -72,7 +72,7 @@ export const swiftOptions = { dense: true, normal: false, } as const, - "dense", + "normal", "secondary", ), linux: new BooleanOption( @@ -91,11 +91,6 @@ export const swiftOptions = { "If no matching case is found enum value is set to null", false, ), - swift5Support: new BooleanOption( - "swift-5-support", - "Renders output in a Swift 5 compatible mode", - false, - ), sendable: new BooleanOption( "sendable", "Mark generated models as Sendable", diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index d833317d86..efcdbb2cee 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -47,6 +47,23 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { return super.namerForObjectProperty(); } + // Flow (pinned at flow-bin 0.66 in CI) has no tuple-rest syntax, so + // the base implementation always renders plain array types; the + // TypeScript renderer overrides this to spell out `minItems` + // guarantees as a tuple. + protected sourceForArrayType(arrayType: ArrayType): MultiWord { + const itemType = this.sourceFor(arrayType.items); + if ( + (arrayType.items instanceof UnionType && + !this._tsFlowOptions.declareUnions) || + arrayType.items instanceof ArrayType + ) { + return singleWord(["Array<", itemType.source, ">"]); + } + + return singleWord([parenIfNeeded(itemType), "[]"]); + } + protected sourceFor(t: Type): MultiWord { if ( this._tsFlowOptions.preferConstValues && @@ -70,18 +87,7 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { (_integerType) => singleWord("number"), (_doubleType) => singleWord("number"), (_stringType) => singleWord("string"), - (arrayType) => { - const itemType = this.sourceFor(arrayType.items); - if ( - (arrayType.items instanceof UnionType && - !this._tsFlowOptions.declareUnions) || - arrayType.items instanceof ArrayType - ) { - return singleWord(["Array<", itemType.source, ">"]); - } - - return singleWord([parenIfNeeded(itemType), "[]"]); - }, + (arrayType) => this.sourceForArrayType(arrayType), (_classType) => panic("We handled this above"), (mapType) => singleWord([ diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts index 1d0d488424..f1c63f6bf3 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts @@ -1,13 +1,25 @@ +import { minMaxItemsForType } from "../../attributes/Constraints.js"; import type { Name } from "../../Naming.js"; -import { type Sourcelike, modifySource } from "../../Source.js"; +import { + type MultiWord, + type Sourcelike, + modifySource, + parenIfNeeded, + singleWord, +} from "../../Source.js"; import { camelCase, utf16StringEscape } from "../../support/Strings.js"; -import type { ClassType, EnumType, Type } from "../../Type/index.js"; +import type { ArrayType, ClassType, EnumType, Type } from "../../Type/index.js"; import { isNamedType } from "../../Type/TypeUtils.js"; import type { JavaScriptTypeAnnotations } from "../JavaScript/index.js"; import { TypeScriptFlowBaseRenderer } from "./TypeScriptFlowBaseRenderer.js"; import { tsFlowTypeAnnotations } from "./utils.js"; +// An array type with a huge `minItems` would otherwise expand into an +// equally huge tuple type, so beyond this limit we fall back to a plain +// array type. +const maxSpelledOutMinItems = 16; + export class TypeScriptRenderer extends TypeScriptFlowBaseRenderer { protected anyType(): string { return this._tsFlowOptions.preferUnknown ? "unknown" : "any"; @@ -17,6 +29,31 @@ export class TypeScriptRenderer extends TypeScriptFlowBaseRenderer { return ["Array", "Date"]; } + // An array with `minItems` >= 1 becomes a tuple that spells out the + // guaranteed elements, followed by a rest element: `minItems: 2` + // renders as `[T, T, ...T[]]`. Only `minItems` shapes the type; + // `maxItems` is enforced by none of the generated code, and spelling + // it out would enumerate every allowed arity as its own tuple. + protected sourceForArrayType(arrayType: ArrayType): MultiWord { + const minItems = minMaxItemsForType(arrayType)?.[0]; + if ( + minItems === undefined || + minItems < 1 || + minItems > maxSpelledOutMinItems + ) { + return super.sourceForArrayType(arrayType); + } + + const itemType = this.sourceFor(arrayType.items); + const source: Sourcelike[] = ["["]; + for (let i = 0; i < minItems; i++) { + source.push(itemType.source, ", "); + } + + source.push("...", parenIfNeeded(itemType), "[]]"); + return singleWord(source); + } + protected uncheckedParsedJson(t: Type, parsedJson: Sourcelike): Sourcelike { // With `raw-type any` and `prefer-unknown` the deserializer's // parameter is `unknown`, which can't be returned as the target diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/language.ts b/packages/quicktype-core/src/language/TypeScriptFlow/language.ts index a894ee2833..0c930a99e6 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/language.ts @@ -32,7 +32,7 @@ export const tsFlowOptions = { preferUnions: new BooleanOption( "prefer-unions", "Use union type instead of enum", - false, + true, ), preferTypes: new BooleanOption( "prefer-types", diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index 6406101476..a487aa7fce 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -1,5 +1,6 @@ import { arrayIntercalate } from "collection-utils"; +import { minMaxItemsForType } from "../../attributes/Constraints.js"; import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import { type Name, type Namer, funPrefixNamer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; @@ -111,11 +112,25 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { (_integerType) => "z.number()", (_doubleType) => "z.number()", (_stringType) => "z.string()", - (arrayType) => [ - "z.array(", - this.typeMapTypeFor(arrayType.items, false), - ")", - ], + (arrayType) => { + const [minItems, maxItems] = + minMaxItemsForType(arrayType) ?? []; + + const arraySource: Sourcelike[] = [ + "z.array(", + this.typeMapTypeFor(arrayType.items, false), + ")", + ]; + if (minItems !== undefined && minItems > 0) { + arraySource.push(".min(", minItems.toString(10), ")"); + } + + if (maxItems !== undefined) { + arraySource.push(".max(", maxItems.toString(10), ")"); + } + + return arraySource; + }, (_classType) => panic("Should already be handled."), (_mapType) => [ "z.record(z.string(), ", @@ -124,9 +139,14 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { ], (_enumType) => panic("Should already be handled."), (unionType) => { - const children = Array.from(unionType.getChildren()).map( - (type: Type) => this.typeMapTypeFor(type, false), - ); + const children = Array.from(unionType.getChildren()) + // Coercing schemas can accept null, so handle it first. + .sort( + (a, b) => + Number(b.kind === "null") - + Number(a.kind === "null"), + ) + .map((type: Type) => this.typeMapTypeFor(type, false)); return ["z.union([", ...arrayIntercalate(", ", children), "])"]; }, (_transformedStringType) => { diff --git a/packages/quicktype-core/src/rewrites/ResolveIntersections.ts b/packages/quicktype-core/src/rewrites/ResolveIntersections.ts index 7f3ab80d84..a7aeefa83a 100644 --- a/packages/quicktype-core/src/rewrites/ResolveIntersections.ts +++ b/packages/quicktype-core/src/rewrites/ResolveIntersections.ts @@ -191,6 +191,9 @@ class IntersectionAccumulator const existing = defined(this._objectProperties).get(name); const newProperty = maybeObject.getProperties().get(name); + // A property declared by any intersection member is known on + // the merged object. A member that doesn't allow additional + // properties must not discard properties declared by others. if (existing !== undefined && newProperty !== undefined) { const cp = new GenericClassProperty( existing.typeData.add(newProperty.type), @@ -206,23 +209,18 @@ class IntersectionAccumulator existing.isOptional, ); defined(this._objectProperties).set(name, cp); - } else if (existing !== undefined) { - defined(this._objectProperties).delete(name); - } else if ( - newProperty !== undefined && - this._additionalPropertyTypes !== undefined - ) { - // FIXME: This is potentially slow - const types = new Set(this._additionalPropertyTypes).add( - newProperty.type, - ); + } else if (newProperty !== undefined) { + const types = + this._additionalPropertyTypes === undefined + ? new Set([newProperty.type]) + : new Set(this._additionalPropertyTypes).add( + newProperty.type, + ); defined(this._objectProperties).set( name, new GenericClassProperty(types, newProperty.isOptional), ); - } else if (newProperty !== undefined) { - defined(this._objectProperties).delete(name); - } else { + } else if (existing === undefined) { mustNotHappen(); } } diff --git a/packages/quicktype-typescript-input/src/index.ts b/packages/quicktype-typescript-input/src/index.ts index e29f49118f..f94edb4007 100644 --- a/packages/quicktype-typescript-input/src/index.ts +++ b/packages/quicktype-typescript-input/src/index.ts @@ -125,6 +125,30 @@ function patchGeneratorForBuiltinTypes( }; } +// typescript-json-schema copies JSDoc `@type {string}` annotations into the +// generated schema verbatim. Strip the JSDoc braces so the value is a valid +// JSON Schema type. +function sanitizeTypeAnnotations(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(sanitizeTypeAnnotations); + return; + } + + if (value === null || typeof value !== "object") { + return; + } + + const schema = value as Record; + if (typeof schema.type === "string") { + const match = /^\{([^{}]+)\}$/.exec(schema.type); + if (match !== null) { + schema.type = match[1]; + } + } + + Object.values(schema).forEach(sanitizeTypeAnnotations); +} + // FIXME: We're stringifying and then parsing this schema again. Just pass around // the schema directly. export function schemaForTypeScriptSources( @@ -151,6 +175,8 @@ export function schemaForTypeScriptSources( patchGeneratorForBuiltinTypes(generator, program); const schema = generateSchema(program, "*", settings, undefined, generator); + sanitizeTypeAnnotations(schema); + const uris: string[] = []; let topLevelName = ""; diff --git a/src/CompressedJSONFromStream.ts b/src/CompressedJSONFromStream.ts index 67e9c05b48..1ce83b0b59 100644 --- a/src/CompressedJSONFromStream.ts +++ b/src/CompressedJSONFromStream.ts @@ -1,5 +1,5 @@ import type { Readable } from "readable-stream"; -import { Parser } from "stream-json"; +import { parser } from "stream-json"; import { CompressedJSON, type Value } from "quicktype-core"; @@ -25,7 +25,7 @@ export class CompressedJSONFromStream extends CompressedJSON { private _currentIntegerString = ""; public async parse(readStream: Readable): Promise { - const combo = new Parser({ packKeys: true, packStrings: true }); + const combo = parser.asStream({ packKeys: true, packStrings: true }); combo.on( "data", (item: { name: string; value: string | undefined }) => { diff --git a/src/index.ts b/src/index.ts index 6ae510e25b..d22d5e70f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -751,7 +751,6 @@ function parseOptions( { argv, partial }, ); } catch (e) { - assert(!partial, "Partial option parsing should not have failed"); return messageError("DriverCLIOptionParsingFailed", { message: exceptionToString(e), }); diff --git a/test/fixtures.ts b/test/fixtures.ts index dece181ef6..827ead7b0b 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -359,14 +359,39 @@ class JSONFixture extends LanguageFixture { return 0; } - compareJsonFileToJson( - comparisonArgs( + // The analog of the JSON Schema fixture's `.out..json` + // convention: a JSON input `foo.json` can come with an expected-output + // file `foo.out..json`, which applies when `` is one of the + // language's `features`, or the name of a renderer option this + // particular run sets (via `quickTestRendererOptions`). When it + // applies, the output must match it exactly, without the usual + // round-trip leniency for null properties. This is how output that + // legitimately differs from the input — e.g. Go's `omitempty` + // dropping null fields — gets asserted. + let expectedFilename = filename; + let strict = false; + const expectedOutputKeys = [ + ...this.language.features, + ...Object.keys(additionalRendererOptions), + ]; + for (const key of expectedOutputKeys) { + const outFilename = filename.replace(/\.json$/, `.out.${key}.json`); + if (fs.existsSync(outFilename)) { + expectedFilename = outFilename; + strict = true; + break; + } + } + + compareJsonFileToJson({ + ...comparisonArgs( this.language, filename, - filename, + expectedFilename, additionalRendererOptions, ), - ); + strict, + }); if ( this.language.diffViaSchema && @@ -461,6 +486,11 @@ class JSONFixture extends LanguageFixture { .flatMap((qt) => { if (Array.isArray(qt)) { const [filename, ro] = qt; + if (filename.endsWith(".schema")) { + // Runs in the JSON Schema fixture instead. + return []; + } + const input = _.find( ([] as string[]).concat( prioritySamples, @@ -747,7 +777,50 @@ class JSONSchemaFixture extends LanguageFixture { getSamples(sources: string[]): { priority: Sample[]; others: Sample[] } { const prioritySamples = testsInDir("test/inputs/schema/", "schema"); - return samplesFromSources(sources, prioritySamples, [], "schema"); + const samples = samplesFromSources( + sources, + prioritySamples, + [], + "schema", + ); + + if (sources.length === 0 && !ONLY_OUTPUT) { + // Pinned-input quick-test entries that name a `.schema` file + // run in this fixture with their renderer options. Plain + // renderer-option combinations and `.json` entries run in + // the JSON fixture. + const quickTestSamples = _.chain( + this.language.quickTestRendererOptions, + ) + .flatMap((qt) => { + if (!Array.isArray(qt)) return []; + + const [filename, ro] = qt; + if (!filename.endsWith(".schema")) return []; + + const input = _.find(prioritySamples, (p) => + p.endsWith(`/${filename}`), + ); + if (input === undefined) { + return failWith( + `quick-test schema ${filename} not found`, + { qt }, + ); + } + + return [ + { + path: input, + additionalRendererOptions: ro, + saveOutput: false, + }, + ]; + }) + .value(); + samples.priority = quickTestSamples.concat(samples.priority); + } + + return samples; } shouldSkipTest(sample: Sample): boolean { @@ -1508,6 +1581,11 @@ class CommandSuccessfulLanguageFixture extends LanguageFixture { .flatMap((qt) => { if (Array.isArray(qt)) { const [filename, ro] = qt; + if (filename.endsWith(".schema")) { + // Runs in the JSON Schema fixture instead. + return []; + } + const input = _.find( ([] as string[]).concat( prioritySamples, @@ -1563,6 +1641,10 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.CJSONMultiHeaderLanguage, "cjson-multi-header"), new JSONFixture(languages.CJSONMultiSplitLanguage, "cjson-multi-split"), new JSONFixture(languages.CPlusPlusLanguage), + new JSONFixture( + languages.CPlusPlusMultiSourceLanguage, + "cplusplus-multi-source", + ), new JSONFixture(languages.PHPLanguage), new JSONFixture(languages.RustLanguage), new JSONFixture(languages.RubyLanguage), @@ -1587,6 +1669,7 @@ export const allFixtures: Fixture[] = [ new JSONSchemaJSONFixture(languages.CSharpLanguage), new JSONTypeScriptFixture(languages.CSharpLanguage), // new JSONSchemaFixture(languages.CrystalLanguage), + new JSONSchemaFixture(languages.JSONSchemaLanguage), new JSONSchemaFixture(languages.CSharpLanguage), new JSONSchemaFixture( languages.CSharpLanguageSystemTextJson, diff --git a/test/fixtures/cplusplus/main.cpp b/test/fixtures/cplusplus/main.cpp index d963530b52..7d070451be 100644 --- a/test/fixtures/cplusplus/main.cpp +++ b/test/fixtures/cplusplus/main.cpp @@ -3,8 +3,7 @@ #include #include -#include "TopLevel.hpp" -#include "Generators.hpp" +#include "quicktype.hpp" using quicktype::TopLevel; using nlohmann::json; 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..d16256ba17 --- /dev/null +++ b/test/fixtures/elm/Warmup.elm @@ -0,0 +1,19 @@ +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 +builds corrupt elm's shared package cache (still reproducible with +elm 0.19.2). +-} + +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..7489e93007 --- /dev/null +++ b/test/fixtures/elm/elm.json @@ -0,0 +1,17 @@ +{ + "type": "application", + "source-directories": ["."], + "elm-version": "0.19.2", + "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/fixtures/schema/main.js b/test/fixtures/schema/main.js new file mode 100644 index 0000000000..4154703efe --- /dev/null +++ b/test/fixtures/schema/main.js @@ -0,0 +1,22 @@ +const fs = require("node:fs"); + +const Ajv = require("ajv"); +const addFormats = require("ajv-formats"); +const draft06MetaSchema = require("ajv/dist/refs/json-schema-draft-06.json"); + +const schema = JSON.parse(fs.readFileSync("TopLevel.schema", "utf8")); +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +const ajv = new Ajv({ ownProperties: true }); +ajv.addMetaSchema(draft06MetaSchema); +addFormats(ajv); +ajv.addVocabulary(["qt-uri-protocols", "qt-uri-extensions"]); +ajv.addFormat("integer", true); +ajv.addFormat("boolean", true); + +if (!ajv.validate(schema, input)) { + console.error(ajv.errorsText()); + process.exit(1); +} + +console.log(JSON.stringify(input)); diff --git a/test/fixtures/typescript/tsconfig.json b/test/fixtures/typescript/tsconfig.json index a4d26e23cd..6bcd3937d1 100644 --- a/test/fixtures/typescript/tsconfig.json +++ b/test/fixtures/typescript/tsconfig.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "es6", - "noEmit": true + "noEmit": true, + "noImplicitAny": true, + "strictNullChecks": false }, "files": ["main.ts"] } diff --git a/test/inputs/json/priority/omit-empty.json b/test/inputs/json/priority/omit-empty.json new file mode 100644 index 0000000000..56425f9b78 --- /dev/null +++ b/test/inputs/json/priority/omit-empty.json @@ -0,0 +1,11 @@ +{ + "results": [ + { + "age": 52, + "name": null + }, + { + "age": 27 + } + ] +} diff --git a/test/inputs/json/priority/omit-empty.out.omit-empty.json b/test/inputs/json/priority/omit-empty.out.omit-empty.json new file mode 100644 index 0000000000..809e3cfa02 --- /dev/null +++ b/test/inputs/json/priority/omit-empty.out.omit-empty.json @@ -0,0 +1,10 @@ +{ + "results": [ + { + "age": 52 + }, + { + "age": 27 + } + ] +} diff --git a/test/inputs/schema/all-of-additional-properties-false.1.fail.enum.json b/test/inputs/schema/all-of-additional-properties-false.1.fail.enum.json new file mode 100644 index 0000000000..d70bdfd082 --- /dev/null +++ b/test/inputs/schema/all-of-additional-properties-false.1.fail.enum.json @@ -0,0 +1,6 @@ +{ + "amount": 1250, + "frequency": "NotAValidFrequency", + "type": "GrossSalaryAmount", + "description": "Base salary" +} diff --git a/test/inputs/schema/all-of-additional-properties-false.1.json b/test/inputs/schema/all-of-additional-properties-false.1.json new file mode 100644 index 0000000000..e7b04d57f3 --- /dev/null +++ b/test/inputs/schema/all-of-additional-properties-false.1.json @@ -0,0 +1,6 @@ +{ + "amount": 1250, + "frequency": "Monthly", + "type": "GrossSalaryAmount", + "description": "Base salary" +} diff --git a/test/inputs/schema/all-of-additional-properties-false.schema b/test/inputs/schema/all-of-additional-properties-false.schema new file mode 100644 index 0000000000..4dc96ac791 --- /dev/null +++ b/test/inputs/schema/all-of-additional-properties-false.schema @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AmountAndFrequency": { + "type": "object", + "properties": { + "amount": { "type": "number" }, + "frequency": { + "type": "string", + "enum": ["Weekly", "Monthly", "Annually"] + } + }, + "additionalProperties": false, + "required": ["amount", "frequency"] + }, + "Income": { + "allOf": [ + { "$ref": "#/definitions/AmountAndFrequency" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["GrossSalaryAmount", "Overtime"] + }, + "description": { "type": "string" } + }, + "additionalProperties": false, + "required": ["type"] + } + ] + } + }, + "$ref": "#/definitions/Income" +} diff --git a/test/inputs/schema/integer-before-number.1.json b/test/inputs/schema/integer-before-number.1.json new file mode 100644 index 0000000000..7ed6ff82de --- /dev/null +++ b/test/inputs/schema/integer-before-number.1.json @@ -0,0 +1 @@ +5 diff --git a/test/inputs/schema/integer-before-number.2.json b/test/inputs/schema/integer-before-number.2.json new file mode 100644 index 0000000000..9ad974f610 --- /dev/null +++ b/test/inputs/schema/integer-before-number.2.json @@ -0,0 +1 @@ +5.5 diff --git a/test/inputs/schema/integer-before-number.3.fail.union.json b/test/inputs/schema/integer-before-number.3.fail.union.json new file mode 100644 index 0000000000..c10b1ae65f --- /dev/null +++ b/test/inputs/schema/integer-before-number.3.fail.union.json @@ -0,0 +1 @@ +"not a number" diff --git a/test/inputs/schema/integer-before-number.schema b/test/inputs/schema/integer-before-number.schema new file mode 100644 index 0000000000..5d8aa8914c --- /dev/null +++ b/test/inputs/schema/integer-before-number.schema @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "description": "An integer or non-integer number", + "type": ["integer", "number"] +} diff --git a/test/inputs/schema/integer-type.1.json b/test/inputs/schema/integer-type.1.json new file mode 100644 index 0000000000..1c281a6c38 --- /dev/null +++ b/test/inputs/schema/integer-type.1.json @@ -0,0 +1,11 @@ +{ + "small_positive": 50, + "small_negative": -50, + "i32_range": 2147483647, + "above_i32_max": 2147483648, + "below_i32_min": -2147483649, + "only_minimum": 3000000000, + "only_maximum": -3000000000, + "unbounded": 9007199254740991, + "large_bounds": -9007199254740991 +} diff --git a/test/inputs/schema/integer-type.schema b/test/inputs/schema/integer-type.schema new file mode 100644 index 0000000000..87ef95310d --- /dev/null +++ b/test/inputs/schema/integer-type.schema @@ -0,0 +1,57 @@ +{ + "type": "object", + "properties": { + "small_positive": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "small_negative": { + "type": "integer", + "minimum": -100, + "maximum": 0 + }, + "i32_range": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "above_i32_max": { + "type": "integer", + "minimum": 0, + "maximum": 2147483648 + }, + "below_i32_min": { + "type": "integer", + "minimum": -2147483649, + "maximum": 0 + }, + "only_minimum": { + "type": "integer", + "minimum": 0 + }, + "only_maximum": { + "type": "integer", + "maximum": 0 + }, + "unbounded": { + "type": "integer" + }, + "large_bounds": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "small_positive", + "small_negative", + "i32_range", + "above_i32_max", + "below_i32_min", + "only_minimum", + "only_maximum", + "unbounded", + "large_bounds" + ] +} diff --git a/test/inputs/schema/light.1.json b/test/inputs/schema/light.1.json new file mode 100644 index 0000000000..478f25fe74 --- /dev/null +++ b/test/inputs/schema/light.1.json @@ -0,0 +1,7 @@ +{ + "LightParams": { + "outlet_id": "outlet-1", + "app_id": "app-1", + "rgba": "255,255,255,1" + } +} diff --git a/test/inputs/schema/light.schema b/test/inputs/schema/light.schema new file mode 100644 index 0000000000..42c4b43b64 --- /dev/null +++ b/test/inputs/schema/light.schema @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "LightParams": { + "type": "object", + "properties": { + "outlet_id": { "type": "string" }, + "app_id": { "type": "string" }, + "rgba": { "type": "string" } + }, + "additionalProperties": false, + "required": ["outlet_id", "app_id", "rgba"] + } + }, + "type": "object", + "properties": { + "LightParams": { "$ref": "#/$defs/LightParams" } + }, + "additionalProperties": false, + "required": ["LightParams"] +} diff --git a/test/inputs/schema/min-max-items.1.fail.minmaxitems.json b/test/inputs/schema/min-max-items.1.fail.minmaxitems.json new file mode 100644 index 0000000000..9e45bbc7ee --- /dev/null +++ b/test/inputs/schema/min-max-items.1.fail.minmaxitems.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one"], + "maxOnly": [1, 2, 3], + "minAndMax": [1.25, 2.5], + "plain": [], + "unionItems": [1, "two", 3] +} diff --git a/test/inputs/schema/min-max-items.1.json b/test/inputs/schema/min-max-items.1.json new file mode 100644 index 0000000000..0d5c53a959 --- /dev/null +++ b/test/inputs/schema/min-max-items.1.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one", "two"], + "maxOnly": [1, 2, 3], + "minAndMax": [1.25, 2.5], + "plain": [], + "unionItems": [1, "two", 3] +} diff --git a/test/inputs/schema/min-max-items.2.fail.minmaxitems.json b/test/inputs/schema/min-max-items.2.fail.minmaxitems.json new file mode 100644 index 0000000000..6db25cd8fb --- /dev/null +++ b/test/inputs/schema/min-max-items.2.fail.minmaxitems.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one", "two", "three"], + "maxOnly": [1, 2, 3, 4], + "minAndMax": [3.75], + "plain": ["only"], + "unionItems": ["one", 2] +} diff --git a/test/inputs/schema/min-max-items.2.json b/test/inputs/schema/min-max-items.2.json new file mode 100644 index 0000000000..7bb633b93d --- /dev/null +++ b/test/inputs/schema/min-max-items.2.json @@ -0,0 +1,7 @@ +{ + "minOnly": ["one", "two", "three"], + "maxOnly": [], + "minAndMax": [3.75], + "plain": ["only"], + "unionItems": ["one", 2] +} diff --git a/test/inputs/schema/min-max-items.schema b/test/inputs/schema/min-max-items.schema new file mode 100644 index 0000000000..7aaf9e8092 --- /dev/null +++ b/test/inputs/schema/min-max-items.schema @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties": { + "minOnly": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 2 + }, + "maxOnly": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 3 + }, + "minAndMax": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 4 + }, + "plain": { + "type": "array", + "items": { + "type": "string" + } + }, + "unionItems": { + "type": "array", + "items": { + "type": ["integer", "string"] + }, + "minItems": 2 + } + }, + "required": ["minOnly", "maxOnly", "minAndMax", "plain", "unionItems"] +} diff --git a/test/inputs/schema/schema-constraints.1.fail.minmaxlength.json b/test/inputs/schema/schema-constraints.1.fail.minmaxlength.json new file mode 100644 index 0000000000..a97fbc7d0d --- /dev/null +++ b/test/inputs/schema/schema-constraints.1.fail.minmaxlength.json @@ -0,0 +1,4 @@ +{ + "minMaxLength": "1234", + "percent": 0.5 +} diff --git a/test/inputs/schema/schema-constraints.1.json b/test/inputs/schema/schema-constraints.1.json new file mode 100644 index 0000000000..e9d5e80644 --- /dev/null +++ b/test/inputs/schema/schema-constraints.1.json @@ -0,0 +1,4 @@ +{ + "minMaxLength": "12345", + "percent": 0.5 +} diff --git a/test/inputs/schema/schema-constraints.2.fail.minmaxlength.json b/test/inputs/schema/schema-constraints.2.fail.minmaxlength.json new file mode 100644 index 0000000000..74468bc773 --- /dev/null +++ b/test/inputs/schema/schema-constraints.2.fail.minmaxlength.json @@ -0,0 +1,4 @@ +{ + "minMaxLength": "123456", + "percent": 0.5 +} diff --git a/test/inputs/schema/schema-constraints.3.fail.minmax.json b/test/inputs/schema/schema-constraints.3.fail.minmax.json new file mode 100644 index 0000000000..6c4d06e986 --- /dev/null +++ b/test/inputs/schema/schema-constraints.3.fail.minmax.json @@ -0,0 +1,4 @@ +{ + "minMaxLength": "12345", + "percent": -0.5 +} diff --git a/test/inputs/schema/schema-constraints.4.fail.minmax.json b/test/inputs/schema/schema-constraints.4.fail.minmax.json new file mode 100644 index 0000000000..15899cab42 --- /dev/null +++ b/test/inputs/schema/schema-constraints.4.fail.minmax.json @@ -0,0 +1,4 @@ +{ + "minMaxLength": "12345", + "percent": 2 +} diff --git a/test/inputs/schema/schema-constraints.schema b/test/inputs/schema/schema-constraints.schema new file mode 100644 index 0000000000..ad847eee04 --- /dev/null +++ b/test/inputs/schema/schema-constraints.schema @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "minMaxLength": { + "type": "string", + "minLength": 5, + "maxLength": 5 + }, + "percent": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["minMaxLength", "percent"] +} diff --git a/test/inputs/schema/unevaluated-properties.1.fail.json b/test/inputs/schema/unevaluated-properties.1.fail.json new file mode 100644 index 0000000000..1b5ea63b1f --- /dev/null +++ b/test/inputs/schema/unevaluated-properties.1.fail.json @@ -0,0 +1,8 @@ +{ + "config": { + "name": "test-config", + "settings": { + "option1": "not-an-item-list" + } + } +} diff --git a/test/inputs/schema/unevaluated-properties.1.json b/test/inputs/schema/unevaluated-properties.1.json new file mode 100644 index 0000000000..a8e22fbc6a --- /dev/null +++ b/test/inputs/schema/unevaluated-properties.1.json @@ -0,0 +1,14 @@ +{ + "config": { + "name": "test-config", + "settings": { + "option1": [ + {"key": "foo", "value": "bar"}, + {"key": "baz", "value": "qux"} + ], + "option2": [ + {"key": "hello", "value": "world"} + ] + } + } +} \ No newline at end of file diff --git a/test/inputs/schema/unevaluated-properties.2.json b/test/inputs/schema/unevaluated-properties.2.json new file mode 100644 index 0000000000..a739f3e56c --- /dev/null +++ b/test/inputs/schema/unevaluated-properties.2.json @@ -0,0 +1,19 @@ +{ + "config": { + "name": "multiple-versions", + "settings": { + "v1.0.0": [ + {"key": "checksum1", "value": "abc123"}, + {"key": "checksum2", "value": "def456"} + ], + "v1.1.0": [ + {"key": "checksum1", "value": "ghi789"} + ], + "latest": [ + {"key": "checksum1", "value": "jkl012"}, + {"key": "checksum2", "value": "mno345"}, + {"key": "checksum3", "value": "pqr678"} + ] + } + } +} \ No newline at end of file diff --git a/test/inputs/schema/unevaluated-properties.schema b/test/inputs/schema/unevaluated-properties.schema new file mode 100644 index 0000000000..1e1382a192 --- /dev/null +++ b/test/inputs/schema/unevaluated-properties.schema @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "unevaluated-properties.schema", + "title": "Test unevaluatedProperties support", + "type": "object", + "properties": { + "config": { + "$ref": "#/$defs/Config" + } + }, + "$defs": { + "Config": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "settings": { + "$ref": "#/$defs/Settings" + } + } + }, + "Settings": { + "type": "object", + "properties": {}, + "unevaluatedProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/Item" + } + } + }, + "Item": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["key", "value"] + } + } +} \ No newline at end of file diff --git a/test/languages.ts b/test/languages.ts index 3b4362efb2..c798e154c4 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"; @@ -26,6 +25,7 @@ const skipsEnumValueValidation = [ "enum-large.schema", "optional-enum.schema", "const-non-string.schema", + "all-of-additional-properties-false.schema", ]; // The language makes no int/double distinction in unions (e.g. an integer is @@ -47,6 +47,15 @@ const skipsUntypedUnions = [ "implicit-class-array-union.schema", ]; +// The generated code does not reject wrong-typed values in typed maps (a +// map accepts values that are not T), so the bare `.fail.json` +// samples for these map-valued schemas do not fail as expected. Add any new +// schema whose fail sample relies on rejecting a mistyped map value. +const skipsMapValueValidation = [ + "go-schema-pattern-properties.schema", + "unevaluated-properties.schema", +]; + export type LanguageFeature = | "enum" | "union" @@ -58,6 +67,7 @@ export type LanguageFeature = | "uuid" | "minmax" | "minmaxlength" + | "minmaxitems" | "pattern"; export interface Language { @@ -82,6 +92,26 @@ export interface Language { sourceFiles?: string[]; } +export const JSONSchemaLanguage: Language = { + name: "schema", + base: "test/fixtures/schema", + runCommand(sample: string) { + return `node main.js "${sample}"`; + }, + diffViaSchema: false, + skipDiffViaSchema: [], + allowMissingNull: false, + features: ["minmax", "minmaxlength", "pattern"], + output: "TopLevel.schema", + topLevel: "TopLevel", + skipJSON: [], + skipMiscJSON: false, + skipSchema: [], + rendererOptions: {}, + quickTestRendererOptions: [], + sourceFiles: ["src/language/JSONSchema/JSONSchemaRenderer.ts"], +}; + export const CSharpLanguage: Language = { name: "csharp", base: "test/fixtures/csharp", @@ -112,13 +142,18 @@ export const CSharpLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "top-level-enum.schema", // The code we generate for top-level enums is incompatible with the driver ], - rendererOptions: { "check-required": "true" }, + // The default framework is SystemTextJson; this fixture deliberately + // pins NewtonSoft so the Newtonsoft renderer keeps end-to-end coverage. + rendererOptions: { "check-required": "true", framework: "NewtonSoft" }, quickTestRendererOptions: [ { "array-type": "list" }, + // The default is csharp-version=8; these keep the older + // language-version code paths covered. { "csharp-version": "5" }, - { "csharp-version": "8" }, + { "csharp-version": "6" }, { density: "dense" }, { "number-type": "decimal" }, { "any-type": "dynamic" }, @@ -154,22 +189,33 @@ export const CSharpLanguageSystemTextJson: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "top-level-enum.schema", // The code we generate for top-level enums is incompatible with the driver // The following skips are pre-existing System.Text.Json renderer issues, // found when first enabling the schema fixture for this language: "keyword-unions.schema", // a property named "JsonSerializer" collides with System.Text.Json.JsonSerializer: CS0120 - "minmaxlength.schema", // generated converter triggers CS8602 warnings, which "dotnet run" prints to stdout, breaking the JSON comparison - "optional-constraints.schema", // same CS8602 stdout issue; also min/max on integers and pattern on optional strings aren't checked, so expected-failure samples don't fail - "optional-const-ref.schema", // same CS8602 stdout issue; also min/max on integers isn't checked, so the expected-failure sample doesn't fail + // minmaxlength.schema, optional-constraints.schema, and + // optional-const-ref.schema used to be skipped here because the + // generated converters triggered CS8602 warnings, which "dotnet + // run" prints to stdout, breaking the JSON comparison. The + // generated code now suppresses CS8602 alongside the other NRT + // pragmas, so they run. (Their .fail..json samples are + // not exercised because this fixture doesn't declare the minmax, + // minmaxlength, or pattern features.) ], rendererOptions: { "check-required": "true", framework: "SystemTextJson" }, quickTestRendererOptions: [ { "array-type": "list" }, + // The default is csharp-version=8; these keep the older + // language-version code paths covered. + { "csharp-version": "5" }, { "csharp-version": "6" }, - { "csharp-version": "8" }, { density: "dense" }, { "number-type": "decimal" }, { "any-type": "dynamic" }, + // Suppressing the DateOnly/TimeOnly converters (for pre-.NET 6 + // targets) must still produce compiling, round-tripping code. + ["unions.json", { "dateonly-timeonly-converters": "false" }], ], sourceFiles: ["src/language/CSharp/index.ts"], }; @@ -195,15 +241,21 @@ export const JavaLanguage: Language = { "nst-test-suite.json", ], skipMiscJSON: false, - skipSchema: ["keyword-unions.schema"], // generates classes with names that are case-insensitively equal + skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. + "keyword-unions.schema", // generates classes with names that are case-insensitively equal + ], rendererOptions: {}, - quickTestRendererOptions: [{ "array-type": "list" }], + // The default is array-type=list; this keeps the T[] code path + // covered. + quickTestRendererOptions: [{ "array-type": "array" }], sourceFiles: ["src/language/Java/index.ts"], }; export const JavaLanguageWithLegacyDateTime: Language = { ...JavaLanguage, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. ...JavaLanguage.skipSchema, "date-time.schema", // Expects less strict serialization. ], @@ -214,13 +266,13 @@ export const JavaLanguageWithLegacyDateTime: Language = { ], skipMiscJSON: true, // Handles edge cases differently and does not allow optional milliseconds. rendererOptions: { "datetime-provider": "legacy" }, - quickTestRendererOptions: [{ "array-type": "list" }], + quickTestRendererOptions: [{ "array-type": "array" }], }; export const JavaLanguageWithLombok: Language = { ...JavaLanguage, base: "test/fixtures/java-lombok", - quickTestRendererOptions: [{ "array-type": "list", lombok: "true" }], + quickTestRendererOptions: [{ "array-type": "array", lombok: "true" }], }; export const PythonLanguage: Language = { @@ -302,14 +354,25 @@ export const RustLanguage: Language = { output: "module_under_test.rs", topLevel: "TopLevel", skipJSON: [], - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. skipMiscJSON: false, rendererOptions: {}, quickTestRendererOptions: [ { density: "dense" }, { visibility: "crate" }, - { visibility: "private" }, - { visibility: "public" }, + // The pre-flip defaults: private fields without Debug/Clone + // derives, kept covered after the defaults changed. + { + visibility: "private", + "derive-debug": "false", + "derive-clone": "false", + }, + // Exercise the integer-type option against schemas with integer + // bounds. force-i32 is pinned to a schema whose sample values + // all fit in i32 so the round-trip still succeeds. + ["integer-type.schema", { "integer-type": "conservative" }], + ["integer-type.schema", { "integer-type": "force-i64" }], + ["minmax-integer.schema", { "integer-type": "force-i32" }], ], sourceFiles: ["src/language/Rust/index.ts"], }; @@ -341,6 +404,7 @@ export const CrystalLanguage: Language = { "e8b04.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // Crystal does not handle enum mapping ...skipsEnumValueValidation, // Crystal does not support top-level primitives @@ -437,6 +501,7 @@ export const RubyLanguage: Language = { "kitchen-sink.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // We don't generate a convenience method for top-level enums "top-level-enum.schema", ], @@ -478,12 +543,18 @@ export const GoLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // can't differenciate empty array and nothing for optional empty array // (omitempty). "postman-collection.schema", ], rendererOptions: {}, - quickTestRendererOptions: [], + quickTestRendererOptions: [ + // Runs against the expected-output file + // `omit-empty.out.omit-empty.json`, which asserts that `omitempty` + // actually drops the null field. + ["omit-empty.json", { "omit-empty": "true" }], + ], sourceFiles: ["src/language/Golang/index.ts"], }; @@ -549,6 +620,7 @@ export const CJSONLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. /* Member names are different when generating with schema */ "vega-lite.schema", /* Enum as TopLevel is not supported */ @@ -559,12 +631,13 @@ export const CJSONLanguage: Language = { ...skipsEnumValueValidation, /* Union, Map and Arrays with invalid types are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ "class-with-additional.schema", - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, "multi-type-enum.schema", "nested-intersection-union.schema", "prefix-items.schema", /* Constraints (min/max and regex) are not supported (for the current implementation, can be added later, should abord parsing and return NULL) */ "minmaxlength.schema", + "schema-constraints.schema", "optional-const-ref.schema", /* Same unsupported min/max, length and regex constraints, applied to optional properties */ "optional-constraints.schema", @@ -609,7 +682,7 @@ export const CJSONDefaultLanguage: Language = { topLevel: "TopLevel", includeJSON: ["nbl-stats.json"], skipMiscJSON: true, - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. rendererOptions: {}, quickTestRendererOptions: [], sourceFiles: ["src/language/CJSON/index.ts"], @@ -633,7 +706,7 @@ export const CJSONMultiHeaderLanguage: Language = { topLevel: "TopLevel", includeJSON: ["nbl-stats.json"], skipMiscJSON: true, - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. rendererOptions: { "source-style": "multi-source" }, quickTestRendererOptions: [], sourceFiles: ["src/language/CJSON/index.ts"], @@ -657,7 +730,7 @@ export const CJSONMultiSplitLanguage: Language = { topLevel: "TopLevel", includeJSON: ["nbl-stats.json"], skipMiscJSON: true, - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. rendererOptions: { "source-style": "multi-source", "header-only": "false", @@ -671,7 +744,8 @@ export const CPlusPlusLanguage: Language = { base: "test/fixtures/cplusplus", setupCommand: "curl -o json.hpp https://raw.githubusercontent.com/nlohmann/json/87df1d6708915ffbfa26a051ad7562ecc22e5579/src/json.hpp", - compileCommand: "g++ -O0 -o quicktype -std=c++17 main.cpp", + compileCommand: + "g++ -O0 -o quicktype -std=c++17 -Werror=unused-parameter main.cpp", runCommand(sample: string) { return `./quicktype "${sample}"`; }, @@ -695,7 +769,7 @@ export const CPlusPlusLanguage: Language = { "union", "no-defaults", ], - output: "TopLevel.hpp", + output: "quicktype.hpp", topLevel: "TopLevel", skipJSON: [ // fails on a string containing null @@ -711,6 +785,7 @@ export const CPlusPlusLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // uses too much memory "keyword-unions.schema", // The generated deserializer accepts non-object values when all class properties are optional. @@ -720,28 +795,53 @@ export const CPlusPlusLanguage: Language = { ], rendererOptions: {}, quickTestRendererOptions: [ - { "source-style": "multi-source" }, { "code-format": "with-struct" }, - { wstring: "use-wstring" }, + // bug2521.json has an optional string, exercising UTF conversion + // through std::optional. + ["bug2521.json", { wstring: "use-wstring" }], { "const-style": "east-const" }, - { boost: "false" }, + // The default is boost=false (C++17); this keeps the boost code + // path covered. Pinned to specific inputs because the default + // quicktest inputs (combinations[1-4].json) are all in this + // fixture's skipJSON, so plain-options quicktests never run for + // C++. unions.json exercises nulls inside unions, where the + // boost and std optional/variant code paths differ. + ["unions.json", { boost: "true" }], + ["pokedex.json", { boost: "true" }], ], sourceFiles: ["src/language/CPlusPlus/index.ts"], }; +export const CPlusPlusMultiSourceLanguage: Language = { + ...CPlusPlusLanguage, + includeJSON: ["pokedex.json"], + rendererOptions: { "source-style": "multi-source" }, + quickTestRendererOptions: [], +}; + export const ElmLanguage: Language = { name: "elm", base: "test/fixtures/elm", - setupCommand: "rm -rf elm-stuff/build-artifacts && elm-make --yes", + // Compiling `Warmup.elm` once up front downloads and builds all package + // dependencies into the shared ELM_HOME cache; elm corrupts its shared + // package cache when parallel compiles race on a cold cache (still + // reproducible with elm 0.19.2). + setupCommand: "rm -rf elm-stuff && elm make Warmup.elm --output=/dev/null", + // The retry after clearing elm-stuff works around the compiler's flaky + // per-project cache locking ("d.dat: withBinaryFile: resource busy", + // elm/compiler#2258), which strikes under heavy parallel load. 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", + "elm make Main.elm --output elm.js || (rm -rf elm-stuff && 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 +867,18 @@ 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 + "integer-before-number.schema", // Python-specific union-order regression. "union-list.schema", // recursion "list.schema", // recursion "ref-remote.schema", // recursion @@ -789,10 +886,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"], }; @@ -847,6 +953,7 @@ export const SwiftLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // The code we generate for top-level enums is incompatible with the driver "top-level-enum.schema", // This works on macOS, but on Linux one of the failure test cases doesn't fail @@ -854,7 +961,7 @@ export const SwiftLanguage: Language = { "required.schema", "multi-type-enum.schema", "intersection.schema", - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, ...skipsEnumValueValidation, "date-time.schema", "class-with-additional.schema", @@ -869,8 +976,9 @@ export const SwiftLanguage: Language = { "simple-object.json", { "struct-or-class": "class", "final-classes": "true" }, ], + // The default is density=normal; this keeps the dense code path + // covered. { density: "dense" }, - { density: "normal" }, { "access-level": "internal" }, { "access-level": "public" }, { protocol: "equatable" }, @@ -911,7 +1019,7 @@ export const ObjectiveCLanguage: Language = { "combinations4.json", ], skipMiscJSON: false, - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. rendererOptions: { functions: "true" }, quickTestRendererOptions: [], sourceFiles: ["src/language/Objective-C/index.ts"], @@ -949,7 +1057,18 @@ export const TypeScriptLanguage: Language = { topLevel: "TopLevel", skipJSON: [], skipMiscJSON: false, - skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property + skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. + "keyword-unions.schema", // can't handle "constructor" property + // Pre-existing failures (this fixture is not in CI yet, and these + // fail with unmodified master too): objects with both declared + // properties and typed additionalProperties render as an interface + // whose properties are not assignable to its index signature + // (TS2411). + "class-map-union.schema", + "class-with-additional.schema", + "vega-lite.schema", + ], rendererOptions: { "explicit-unions": "yes" }, quickTestRendererOptions: [ { "runtime-typecheck": "false" }, @@ -959,6 +1078,9 @@ export const TypeScriptLanguage: Language = { { "acronym-style": "pascal" }, { converters: "all-objects" }, { readonly: "true" }, + // The default is prefer-unions=true; this keeps the TypeScript + // enum code path covered. + { "prefer-unions": "false" }, { "prefer-unknown": "false" }, ], sourceFiles: ["src/language/TypeScript/index.ts"], @@ -979,7 +1101,10 @@ export const JavaScriptLanguage: Language = { topLevel: "TopLevel", skipJSON: [], skipMiscJSON: false, - skipSchema: ["keyword-unions.schema"], // can't handle "constructor" property + skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. + "keyword-unions.schema", // can't handle "constructor" property + ], rendererOptions: {}, quickTestRendererOptions: [ { "runtime-typecheck": "false" }, @@ -1010,7 +1135,7 @@ export const JavaScriptPropTypesLanguage: Language = { "spotify-album.json", // renderer does not support recursion "76ae1.json", // renderer does not support recursion ], - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. skipMiscJSON: false, rendererOptions: { "module-system": "es6" }, quickTestRendererOptions: [{ converters: "top-level" }], @@ -1032,6 +1157,7 @@ export const FlowLanguage: Language = { skipJSON: [], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "keyword-unions.schema", // can't handle "constructor" property ], rendererOptions: { "explicit-unions": "yes" }, @@ -1039,6 +1165,10 @@ export const FlowLanguage: Language = { { "runtime-typecheck": "false" }, { "runtime-typecheck-ignore-unknown-properties": "true" }, { "nice-property-names": "true" }, + // Flow always renders enums as unions of string literals, so + // this only asserts that the flipped default stays a no-op for + // Flow output. + { "prefer-unions": "false" }, { "prefer-unknown": "false" }, ], sourceFiles: ["src/language/Flow/index.ts"], @@ -1084,6 +1214,7 @@ export const Scala3Language: Language = { "bug2521.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // Same raw-identifier limitation as in skipJSON: a property // named "_" and classes shadowing None/Option don't compile. "keyword-unions.schema", @@ -1134,6 +1265,7 @@ export const Scala3UpickleLanguage: Language = { "bug2521.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // Same raw-identifier limitation as in skipJSON: a property // named "_" and classes shadowing None/Option don't compile. "keyword-unions.schema", @@ -1204,7 +1336,7 @@ I havea no idea how to encode these tests correctly. "php-mixed-union.json", "nst-test-suite.json", ], - skipSchema: [], + skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. skipMiscJSON: false, rendererOptions: { "just-types": "true" }, quickTestRendererOptions: [], @@ -1266,6 +1398,7 @@ export const KotlinLanguage: Language = { "bug427.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // Very weird - the types are correct, but it can (de)serialize the string, // which is not represented in the types (implicit-class-array-union); // class-map-union: KlaxonException: Couldn't find a suitable constructor for class UnionValue to initialize with {} @@ -1274,7 +1407,7 @@ export const KotlinLanguage: Language = { // instead of rejecting it. "nested-intersection-union.schema", "class-with-additional.schema", - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, // IllegalArgumentException "accessors.schema", "description.schema", @@ -1296,7 +1429,9 @@ export const KotlinLanguage: Language = { "recursive-union-flattening.schema", ], skipMiscJSON: false, - rendererOptions: {}, + // The default framework is jackson; this fixture deliberately pins + // klaxon so the Klaxon renderer keeps end-to-end coverage. + rendererOptions: { framework: "klaxon" }, quickTestRendererOptions: [], sourceFiles: ["src/language/Kotlin/index.ts"], }; @@ -1355,6 +1490,7 @@ export const KotlinJacksonLanguage: Language = { "bug427.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // Very weird - the types are correct, but it can (de)serialize the string, // which is not represented in the types (implicit-class-array-union); // class-map-union: KlaxonException: Couldn't find a suitable constructor for class UnionValue to initialize with {} @@ -1363,7 +1499,7 @@ export const KotlinJacksonLanguage: Language = { // instead of rejecting it. "nested-intersection-union.schema", "class-with-additional.schema", - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, // IllegalArgumentException "accessors.schema", "description.schema", @@ -1490,6 +1626,7 @@ export const KotlinXLanguage: Language = { "identifiers.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // Unions render as sealed classes without serializer wiring, so // deserialization fails at runtime (documented TODO in // KotlinXRenderer.ts). @@ -1507,6 +1644,7 @@ export const KotlinXLanguage: Language = { "implicit-class-array-union.schema", "integer-float-union.schema", "integer-string.schema", + "min-max-items.schema", // unionItems is an int|string union array "minmaxlength.schema", "multi-type-enum.schema", "mutually-recursive.schema", @@ -1558,6 +1696,7 @@ export const DartLanguage: Language = { "keywords.json", ], skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "enum-with-null.schema", // Deliberately NOT ...skipsEnumValueValidation: Dart runs // optional-enum.schema as its own regression test (see PR #2720), @@ -1576,13 +1715,16 @@ export const DartLanguage: Language = { "keyword-unions.schema", "ref-remote.schema", "uuid.schema", - /* Absent optional lists don't round-trip: the generated fromJson/toJson - turn them into [], so the output no longer matches the input */ - "optional-const-ref.schema", ], skipMiscJSON: true, rendererOptions: {}, - quickTestRendererOptions: [], + // The default is final-props=true; this keeps the mutable-property + // code path covered. The targeted from-map sample also verifies that + // the fixture driver can keep calling the top-level JSON string helpers. + quickTestRendererOptions: [ + { "final-props": "false" }, + ["simple-object.json", { "from-map": "true" }], + ], sourceFiles: ["src/language/Dart/index.ts"], }; @@ -1623,12 +1765,13 @@ export const PikeLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "top-level-enum.schema", // output generated properly, but not a class "keyword-unions.schema", // seems like a problem with deserializing // no implicit cast int <-> float in Pike ...skipsIntFloatUnions, // all below: not failing on expected failure. That's because Pike's quite tolerant with assignments. - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, "class-with-additional.schema", "multi-type-enum.schema", ...skipsUntypedUnions, @@ -1709,6 +1852,7 @@ export const HaskellLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "any.schema", ...skipsUntypedUnions, // The test driver encodes the Maybe result, so a failed decode prints @@ -1717,7 +1861,7 @@ export const HaskellLanguage: Language = { "prefix-items.schema", "direct-union.schema", ...skipsEnumValueValidation, - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, "intersection.schema", "multi-type-enum.schema", "keyword-unions.schema", @@ -1726,7 +1870,9 @@ export const HaskellLanguage: Language = { "required-non-properties.schema", ], rendererOptions: {}, - quickTestRendererOptions: [{ "array-type": "list" }], + // The default is array-type=list; this keeps the Vector code path + // covered. + quickTestRendererOptions: [{ "array-type": "array" }], sourceFiles: ["src/language/Haskell/index.ts"], }; @@ -1763,6 +1909,7 @@ export const PHPLanguage: Language = { ], skipMiscJSON: true, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // PHP class names are case-insensitive, but the namer dedups // case-sensitively, so this declares classes that collide (same // reason Java and Python skip it). @@ -1815,7 +1962,7 @@ export const TypeScriptZodLanguage: Language = { "e8b04.json", ], allowMissingNull: false, - features: ["enum", "union", "no-defaults", "date-time"], + features: ["enum", "union", "no-defaults", "date-time", "minmaxitems"], output: "TopLevel.ts", topLevel: "TopLevel", skipJSON: [ @@ -1830,9 +1977,6 @@ export const TypeScriptZodLanguage: Language = { // Does not handle top level array "bug863.json", - // z.coerce.date() coerces null to the Unix epoch: #2880 - "bug2590.json", - "no-classes.json", "00c36.json", "10be4.json", @@ -1875,6 +2019,7 @@ export const TypeScriptZodLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "any.schema", ...skipsUntypedUnions, "direct-union.schema", @@ -1884,7 +2029,7 @@ export const TypeScriptZodLanguage: Language = { "constructor.schema", // z.coerce.date() serializes back as ISO UTC, not the input string "date-time.schema", - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, "intersection.schema", "multi-type-enum.schema", "keyword-unions.schema", @@ -1996,11 +2141,12 @@ export const TypeScriptEffectSchemaLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. "any.schema", ...skipsUntypedUnions, "direct-union.schema", ...skipsEnumValueValidation, - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, "intersection.schema", "multi-type-enum.schema", "keyword-unions.schema", @@ -2056,6 +2202,7 @@ export const ElixirLanguage: Language = { ], skipMiscJSON: false, skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. // The error occurs because a guard clause that references TopLevel is compiled before TopLevel itself. To fix this, put // TopLevel before Bar, but this doesn't address the actual problem if for example a pattern match to Bar was in TopLevel. "mutually-recursive.schema", @@ -2067,7 +2214,7 @@ export const ElixirLanguage: Language = { // The test incorrectly succeeds due to the emitter being permissive for unions that contain only primitives. A future enhancement // for the Elixir emitter could be a user-controlled 'strict' mode that pattern matches even on unions of only primitive types. - "go-schema-pattern-properties.schema", + ...skipsMapValueValidation, // The generated top-level type is not emitted as a TopLevel module the fixture can call. "recursive-union-flattening.schema", diff --git a/test/test.ts b/test/test.ts index dd9a1933a5..a3c87d1d29 100755 --- a/test/test.ts +++ b/test/test.ts @@ -85,5 +85,5 @@ async function main(sources: string[]) { // skip 2 `node` args main(process.argv.slice(2)).catch((reason) => { console.error(reason); - process.process.exit(1); + process.exit(1); }); diff --git a/test/unit/cjson-enum-default.test.ts b/test/unit/cjson-enum-default.test.ts new file mode 100644 index 0000000000..b2c02d60ee --- /dev/null +++ b/test/unit/cjson-enum-default.test.ts @@ -0,0 +1,37 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + subscription: { + type: "string", + enum: ["state", "config", "heartbeat"], + }, + }, + required: ["subscription"], +}); + +async function cJSONOutput(): 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: "cjson" }); + return result.lines.join("\n"); +} + +describe("cJSON enum invalid value", () => { + test("does not collide with a real enumerator", async () => { + const output = await cJSONOutput(); + + expect(output).toContain(`enum Subscription { + SUBSCRIPTION_CONFIG = 1, + SUBSCRIPTION_HEARTBEAT, + SUBSCRIPTION_STATE, +};`); + expect(output).toContain("enum Subscription x = 0;"); + }); +}); diff --git a/test/unit/cli-options.test.ts b/test/unit/cli-options.test.ts new file mode 100644 index 0000000000..44b22288c9 --- /dev/null +++ b/test/unit/cli-options.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { parseCLIOptions } from "../../src/index.js"; + +describe("CLI option parsing", () => { + test("reports a duplicate output option as a user input error", () => { + expect(() => + parseCLIOptions(["-o", "list.go", "-o", "list.ts"]), + ).toThrow(/^Option parsing failed: .*Singular option already set/); + + expect(() => + parseCLIOptions(["-o", "list.go", "-o", "list.ts"]), + ).not.toThrow("Internal error"); + }); +}); diff --git a/test/unit/cplusplus-multi-source.test.ts b/test/unit/cplusplus-multi-source.test.ts new file mode 100644 index 0000000000..734dca7f1f --- /dev/null +++ b/test/unit/cplusplus-multi-source.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { + InputData, + jsonInputForTargetLanguage, + quicktypeMultiFile, +} from "quicktype-core"; + +async function cPlusPlusMultiSourceFiles(): Promise> { + const jsonInput = jsonInputForTargetLanguage("cplusplus"); + await jsonInput.addSource({ + name: "ChunkCache", + samples: ['{"chunks":["one"],"size":1}'], + }); + await jsonInput.addSource({ + name: "BufferPath", + samples: ['{"path":"somewhere","maxSize":2}'], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktypeMultiFile({ + inputData, + lang: "cplusplus", + outputFilename: "quicktype.hpp", + rendererOptions: { "source-style": "multi-source" }, + }); + + return new Map( + Array.from(result, ([filename, serialized]) => [ + filename, + serialized.lines.join("\n"), + ]), + ); +} + +describe("C++ multi-source output", () => { + test("the umbrella header includes the JSON generators", async () => { + const files = await cPlusPlusMultiSourceFiles(); + expect(files.get("quicktype.hpp")).toContain( + '#include "Generators.hpp"', + ); + }); + + test("usage comments name top-level types, not generated files", async () => { + const files = await cPlusPlusMultiSourceFiles(); + for (const [filename, source] of files) { + expect(source).toContain( + "// ChunkCache data = nlohmann::json::parse(jsonString);", + ); + expect(source).toContain( + "// BufferPath data = nlohmann::json::parse(jsonString);", + ); + expect(source).not.toContain( + `// ${filename} data = nlohmann::json::parse(jsonString);`, + ); + } + }); +}); diff --git a/test/unit/csharp-dateonly-timeonly-converters.test.ts b/test/unit/csharp-dateonly-timeonly-converters.test.ts new file mode 100644 index 0000000000..8824348605 --- /dev/null +++ b/test/unit/csharp-dateonly-timeonly-converters.test.ts @@ -0,0 +1,59 @@ +import { + InputData, + JSONSchemaInput, + type RendererOptions, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderCSharp( + rendererOptions: RendererOptions = {}, +): Promise { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer" }, + }, + required: ["name", "count"], + }; + + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "csharp", + rendererOptions: { framework: "SystemTextJson", ...rendererOptions }, + }); + return result.lines.join("\n"); +} + +describe("C# System.Text.Json DateOnly/TimeOnly converters", () => { + test("emits the converters by default", async () => { + const output = await renderCSharp(); + + expect(output).toContain("class DateOnlyConverter"); + expect(output).toContain("class TimeOnlyConverter"); + expect(output).toContain("new DateOnlyConverter(),"); + expect(output).toContain("new TimeOnlyConverter(),"); + }); + + test("omits the converters with dateonly-timeonly-converters=false", async () => { + const output = await renderCSharp({ + "dateonly-timeonly-converters": "false", + }); + + expect(output).not.toContain("DateOnlyConverter"); + expect(output).not.toContain("TimeOnlyConverter"); + // The DateTimeOffset converter is unaffected. + expect(output).toContain("class IsoDateTimeOffsetConverter"); + expect(output).toContain("IsoDateTimeOffsetConverter.Singleton"); + }); +}); diff --git a/test/unit/csharp-system-text-json-exceptions.test.ts b/test/unit/csharp-system-text-json-exceptions.test.ts new file mode 100644 index 0000000000..04ddc339b7 --- /dev/null +++ b/test/unit/csharp-system-text-json-exceptions.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; + +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +async function renderSystemTextJsonCSharp(schema: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "csharp", + rendererOptions: { framework: "SystemTextJson" }, + }); + return result.lines.join("\n"); +} + +describe("C# System.Text.Json converters", () => { + test("throw serializer-supported exceptions for union conversion failures", async () => { + const output = await renderSystemTextJsonCSharp({ + type: "object", + properties: { + mixed: { oneOf: [{ type: "integer" }, { type: "string" }] }, + }, + required: ["mixed"], + }); + + expect(output).toContain( + 'throw new JsonException("Cannot unmarshal type Mixed");', + ); + expect(output).toContain( + 'throw new NotSupportedException("Cannot marshal type Mixed");', + ); + expect(output).not.toContain("throw new Exception("); + }); +}); diff --git a/test/unit/dart-method-names.test.ts b/test/unit/dart-method-names.test.ts new file mode 100644 index 0000000000..17ed1e35c9 --- /dev/null +++ b/test/unit/dart-method-names.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; + +import { + InputData, + type RendererOptions, + jsonInputForTargetLanguage, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +async function renderDart( + rendererOptions: RendererOptions = {}, +): Promise { + const jsonInput = jsonInputForTargetLanguage("dart"); + await jsonInput.addSource({ + name: "Sensordata", + samples: ['{"sensor":"temp","data":[1,2,3]}'], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + + const result = await quicktype({ + inputData, + lang: "dart", + rendererOptions, + }); + return result.lines; +} + +describe("Dart JSON method names", () => { + test("from-map only renames class-level map methods", async () => { + const lines = await renderDart({ "from-map": "true" }); + + expect(lines).toContain( + "Sensordata sensordataFromJson(String str) => Sensordata.fromMap(json.decode(str));", + ); + expect(lines).toContain( + "String sensordataToJson(Sensordata data) => json.encode(data.toMap());", + ); + expect(lines).toContain( + " factory Sensordata.fromMap(Map json) => Sensordata(", + ); + expect(lines).toContain(" Map toMap() => {"); + expect(lines.some((line) => line.includes("sensordataFromMap"))).toBe( + false, + ); + expect(lines.some((line) => line.includes("sensordataToMap"))).toBe( + false, + ); + }); + + test("uses Json names at both levels by default", async () => { + const lines = await renderDart(); + + expect(lines).toContain( + "Sensordata sensordataFromJson(String str) => Sensordata.fromJson(json.decode(str));", + ); + expect(lines).toContain( + "String sensordataToJson(Sensordata data) => json.encode(data.toJson());", + ); + expect(lines).toContain( + " factory Sensordata.fromJson(Map json) => Sensordata(", + ); + expect(lines).toContain(" Map toJson() => {"); + expect(lines.some((line) => line.includes("fromMap"))).toBe(false); + expect(lines.some((line) => line.includes("toMap"))).toBe(false); + }); +}); diff --git a/test/unit/enum-order.test.ts b/test/unit/enum-order.test.ts new file mode 100644 index 0000000000..da930ac15c --- /dev/null +++ b/test/unit/enum-order.test.ts @@ -0,0 +1,32 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { expect, test } from "vitest"; + +const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-04/schema#", + title: "Test", + type: "object", + properties: { + errorCode: { + type: "string", + enum: ["B", "A", "E"], + }, + }, +}); + +test("preserves JSON Schema enum case order", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "Test", schema }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "c++" }); + + expect(result.lines).toContain( + " enum class ErrorCode : int { B, A, E };", + ); +}); diff --git a/test/unit/json-schema-definitions.test.ts b/test/unit/json-schema-definitions.test.ts new file mode 100644 index 0000000000..f63702d495 --- /dev/null +++ b/test/unit/json-schema-definitions.test.ts @@ -0,0 +1,51 @@ +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { expect, test } from "vitest"; + +const lightSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $defs: { + LightParams: { + type: "object", + properties: { + outlet_id: { type: "string" }, + app_id: { type: "string" }, + rgba: { type: "string" }, + }, + additionalProperties: false, + required: ["outlet_id", "app_id", "rgba"], + }, + }, + type: "object", + properties: { + LightParams: { $ref: "#/$defs/LightParams" }, + }, + additionalProperties: false, + required: ["LightParams"], +}; + +test("a type under $defs keeps its definition name (issue #2778)", async () => { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ + name: "LightSchema", + schema: JSON.stringify(lightSchema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": "true" }, + }); + const output = result.lines.join("\n"); + + // The fixture exercises this schema end-to-end, but generated symbol names + // are not observable at runtime, so assert the regression here. + expect(output).toContain("LightParams: LightParams;"); + expect(output).toContain("export interface LightParams {"); +}); diff --git a/test/unit/just-types-option.test.ts b/test/unit/just-types-option.test.ts index 8ae56e317a..26f435bbae 100644 --- a/test/unit/just-types-option.test.ts +++ b/test/unit/just-types-option.test.ts @@ -56,6 +56,23 @@ describe("just-types generates plain types in every language", () => { }); }); +describe("C# generated-file marker", () => { + test.each([ + ["SystemTextJson attributes-only", { features: "attributes-only" }], + [ + "NewtonSoft attributes-only", + { framework: "NewtonSoft", features: "attributes-only" }, + ], + ["just-types", { "just-types": true }], + ["complete", { features: "complete" }], + ] as Array< + [string, RendererOptions] + >)("%s", async (_name, rendererOptions) => { + const output = await linesFor("csharp", rendererOptions); + expect(output.startsWith("// \n//\n")).toBe(true); + }); +}); + describe("the removed enum spellings are errors", () => { // The old spellings aren't valid `RendererOptions` anymore, which is // the point: they must also fail for API callers who evade the types. @@ -131,8 +148,8 @@ describe("framework defaults", () => { expect(output).toContain("io.circe"); }); - test("Kotlin defaults to Klaxon", async () => { + test("Kotlin defaults to Jackson", async () => { const output = await linesFor("kotlin"); - expect(output).toContain("com.beust.klaxon"); + expect(output).toContain("com.fasterxml.jackson"); }); }); diff --git a/test/unit/missing-schema-path.test.ts b/test/unit/missing-schema-path.test.ts new file mode 100644 index 0000000000..8656b820dd --- /dev/null +++ b/test/unit/missing-schema-path.test.ts @@ -0,0 +1,36 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { expect, test } from "vitest"; + +// Regression test for issue #2812: shells such as PowerShell pass wildcard +// arguments through literally, so a schema wildcard with no matching file must +// report a normal missing-file error rather than an internal error. +test("missing JSON Schema paths report the fetch error", async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "quicktype-missing-schema-"), + ); + const missingPath = path.join(tempDir, "*.json"); + + try { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "TopLevel", uris: [missingPath] }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + await expect( + quicktype({ inputData, lang: "typescript" }), + ).rejects.toThrow( + `Could not fetch schema #, referred to from ${missingPath}#`, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/test/unit/python-union-order.test.ts b/test/unit/python-union-order.test.ts new file mode 100644 index 0000000000..9487c2ff72 --- /dev/null +++ b/test/unit/python-union-order.test.ts @@ -0,0 +1,26 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { expect, test } from "vitest"; + +const schema = JSON.stringify({ + type: ["integer", "number"], +}); + +// Schema fixtures use JSON.parse, which unifies JSON integers and floats as +// JavaScript numbers (5 and 5.0 compare identically), so assert source order. +test("Python tries integer before number in unions", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "TopLevel", schema }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "python" }); + const output = result.lines.join("\n"); + + expect(output).toContain("from_union([from_int, from_float]"); + expect(output).toContain("from_union([from_int, to_float]"); +}); diff --git a/test/unit/typescript-input.test.ts b/test/unit/typescript-input.test.ts index 2e5f6a8842..dedb3335d2 100644 --- a/test/unit/typescript-input.test.ts +++ b/test/unit/typescript-input.test.ts @@ -84,6 +84,23 @@ describe("schemaForTypeScriptSources", () => { expect(schema.definitions.Person.type).toBe("object"); }); + // https://github.com/glideapps/quicktype/issues/2695 + test("strips braces from JSDoc type annotations", () => { + const { schema } = schemaForSource(` + export type Person = { + /** + * @type {string} + * @memberOf {Person} + */ + name: string; + }; + + export type MemberInfo = Required; + `); + + expect(schema.definitions.Person.properties.name.type).toBe("string"); + }); + test("class property initializers become defaults", () => { const { schema } = schemaForSource(` export class Config { diff --git a/test/unit/url-input.test.ts b/test/unit/url-input.test.ts index eeb17002c0..24807a8adb 100644 --- a/test/unit/url-input.test.ts +++ b/test/unit/url-input.test.ts @@ -19,6 +19,7 @@ import * as path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { readFromFileOrURL } from "../../packages/quicktype-core/src/input/io/NodeIO.js"; import { main as quicktype } from "../../src"; const files: Record = { @@ -74,6 +75,12 @@ async function generateTypeScript( beforeAll(async () => { server = http.createServer((request, response) => { + if (path.basename(request.url ?? "") === "unauthorized.json") { + response.writeHead(401, { "Content-Type": "application/json" }); + response.end('{"message":"Bad credentials"}'); + return; + } + const content = files[path.basename(request.url ?? "")]; if (content === undefined) { response.writeHead(404); @@ -105,6 +112,12 @@ describe("native fetch URL inputs", () => { expect(output).toContain("veryUniquePropertyName"); }); + test("rejects an HTTP error response", async () => { + await expect( + readFromFileOrURL(`${baseURL}/unauthorized.json`), + ).rejects.toThrow("HTTP 401 Unauthorized"); + }); + test("resolves a relative remote JSON Schema reference", async () => { const output = await generateTypeScript("schema", "main.schema"); expect(output).toContain("veryUniqueReferencedProperty"); diff --git a/test/utils.ts b/test/utils.ts index db98e3101b..3a6d1f4bc2 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -186,7 +186,11 @@ export async function inDir(dir: string, work: () => Promise) { } export function testsInDir(dir: string, extension: string): string[] { - return shell.ls(`${dir}/*.${extension}`); + // Expected-output files (`foo.out..json`) accompany test inputs; + // they are never test inputs themselves. + return shell + .ls(`${dir}/*.${extension}`) + .filter((fn) => !/\.out\.[^./]+\.[^./]+$/.test(fn)); } export interface Sample {