diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 997807e906..9287a6b0c4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- -name: New Bug Report -about: Use this template for reporting new bugs. -title: "[BUG]: bug description here" +name: Bug report +about: Report a reproducible problem with quicktype +title: "[BUG]: " labels: bug --- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index dd39e6d267..3ba13e0cec 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1 @@ blank_issues_enabled: false -# contact_links: -# - name: GitHub Community Support -# url: https://github.com/orgs/community/discussions -# about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 5aab66de0c..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: Feature Request -about: Use this template for requesting new features. -title: "[FEATURE]: feature description here" -labels: enhancement ---- - - - -## Context (Input, Language) - - - -Input Format: -Output Language: - -## Description - - - - -## Current Behaviour / Output - - - -## Proposed Behaviour / Output - - - -## Solution - - - -## Alternatives - - - -## Context - - diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000000..b4523326e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,13 @@ +--- +name: Question +about: Ask a question about using quicktype +title: "[QUESTION]: " +--- + +## Question + + + +## Context + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 86eeba0109..7f63750ad2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,8 @@ ## Related Issue - - - + + ## Motivation and Context diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 79d83a6966..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,73 +0,0 @@ -# AGENTS.md - -Notes for coding agents working in this repository. - -## Environment - -- This is a TypeScript/npm monorepo using npm workspaces. -- Prefer the Node version from `.nvmrc` (`nvm use`; currently Node 24.6.0). Published packages support Node >= 20. -- Install dependencies with `npm ci`. - -## Build and run - -- Build everything with: - - ```bash - npm run build - ``` - - This runs `npm run clean`, builds all workspaces that have a `build` script, then runs the root `tsc`. - -- After building, the CLI entry point is `dist/index.js`, for example: - - ```bash - node dist/index.js --version - node dist/index.js --help - ``` - -- For live rebuild/re-run while developing renderer output, use `npm start -- ""`. - -## Tests - -- Vitest runs the standalone unit and regression tests: - - ```bash - npm run test:unit - npm run test:unit:watch - ``` - -- The cross-language fixture runner remains `script/test`, exposed as: - - ```bash - npm run test:fixtures - ``` - -- `npm test` runs the Vitest suite followed by the fixture suite. - -- The full suite runs all fixtures and needs external language toolchains for many targets (`dotnet`, Java/Maven, Go, Rust, Python/mypy, PHP, Ruby, Kotlin, Scala, Elixir, etc.). On a machine without those tools, plain `npm test` will fail when it reaches the first missing toolchain. - -- For local focused testing, use fixture filters. Fixture names are in `test/languages.ts` and `test/fixtures.ts`; comma-separated fixture groups are supported: - - ```bash - QUICKTEST=true FIXTURE=javascript npm run test:fixtures - QUICKTEST=true FIXTURE=typescript npm run test:fixtures -- test/inputs/json/samples/pokedex.json - CPUs=2 QUICKTEST=true FIXTURE=javascript npm run test:fixtures - ``` - - `QUICKTEST=true` skips the large miscellaneous JSON input set. Extra arguments after `--` are sample files or directories to run. - -- GitHub Actions uses the same pattern, e.g. `QUICKTEST=true FIXTURE=${{ matrix.fixture }} npm run test:fixtures`, after installing toolchain dependencies for each fixture group in `.github/workflows/test-pr.yaml`. - -## Validation performed - -The following commands were run successfully in this workspace: - -```bash -npm ci -npm run build -node dist/index.js --version -CPUs=2 QUICKTEST=true FIXTURE=javascript npm run test:fixtures -QUICKTEST=true FIXTURE=typescript npm run test:fixtures -- test/inputs/json/samples/pokedex.json -``` - -Also observed: `npm test` without fixture filters started the full 70-fixture suite and failed on this machine because `dotnet` is not installed. `npm run lint` currently fails because ESLint cannot find a configuration file. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7a769ca160..50f416ca77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,69 +1,108 @@ # Repository conventions +## Environment + +- This is a TypeScript/npm monorepo using npm workspaces. +- Prefer the Node version from `.nvmrc` (`nvm use`; currently Node 24.6.0). The root CLI package requires Node >= 20.19.0; the library workspaces require Node >= 20.0.0. +- Install dependencies with `npm ci`. + +## Build and run + +Build everything with: + +```bash +npm run build +``` + +This runs `npm run clean`, builds all workspaces that have a `build` script, and then runs the root `tsc`. + +After building, the CLI entry point is `dist/index.js`: + +```bash +node dist/index.js --version +node dist/index.js --help +``` + +For live rebuild/re-run while developing renderer output, use: + +```bash +npm start -- "" +``` + ## Testing -quicktype's primary testing method is end-to-end fixture tests driven by JSON -and JSON Schema files. For each sample input, the fixture generates code for a -language, runs a driver program in `test/fixtures//` that -deserializes the sample and serializes it back, and compares the round-tripped -JSON to the input. - -- JSON inputs live in `test/inputs/json/`: `priority/` and `samples/` always - run, `misc/` is skipped under `QUICKTEST` (and for languages with - `skipMiscJSON`). -- JSON Schema inputs live in `test/inputs/schema/`: each `*.schema` comes with - `.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`. -- Run one language's fixtures with `FIXTURE= script/test`, for example - `FIXTURE=php script/test` or `FIXTURE=schema-php script/test`. - -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. 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. +### Test strategy + +quicktype's primary testing method is end-to-end fixture tests driven by JSON and JSON Schema files. For each sample input, a fixture generates code for a language, runs a driver program in `test/fixtures//` that deserializes the sample and serializes it back, and compares the round-tripped JSON to the input. + +Any change that affects generated output **must** be covered by a JSON or JSON Schema fixture test, either by enabling existing inputs for the language or by adding new ones. Unit tests in `test/unit/` complement fixtures for behavior that fixtures cannot express, such as asserting that code is not generated, API-level behavior, or fast local iteration. Do not add a unit test when a fixture test already covers the behavior. + +### Fixture layout and configuration + +- JSON inputs live in `test/inputs/json/`. `priority/` and `samples/` form the default input set; `misc/` is omitted under `QUICKTEST` and for languages with `skipMiscJSON`. Per-language `skipJSON` and `includeJSON` settings can further restrict inputs. +- JSON Schema inputs live in `test/inputs/schema/`. A `*.schema` can have a same-basename `.json` sample and numbered `.N.json` samples. Numbered `.N.fail..json` files are expected failures for languages that declare that feature; `.N.fail.json` files apply regardless of features. An expected-failure sample must make the generated program exit nonzero. +- New schema fixture tests should have at least one positive and one negative test case unless there is a compelling reason not to. +- Per-language configuration—input filters, renderer options, and `features`—lives in `test/languages.ts`. +- Fixtures and their filter names are registered in `test/fixtures.ts`; driver programs live in `test/fixtures//`. + +### Test commands + +Run the standalone Vitest unit and regression tests with: + +```bash +npm run test:unit +npm run test:unit:watch +``` + +Run fixture tests with: + +```bash +npm run test:fixtures +``` + +Use fixture filters for focused local testing. Fixture names are registered in `test/fixtures.ts`; comma-separated groups are supported: + +```bash +QUICKTEST=true FIXTURE=javascript npm run test:fixtures +QUICKTEST=true FIXTURE=typescript npm run test:fixtures -- test/inputs/json/samples/pokedex.json +FIXTURE=php npm run test:fixtures +FIXTURE=schema-php npm run test:fixtures +CPUs=2 QUICKTEST=true FIXTURE=javascript npm run test:fixtures +``` + +`QUICKTEST=true` skips the large miscellaneous JSON input set. Arguments after `--` select sample files or directories. + +`npm test` runs the Vitest suite followed by all fixture tests. The full fixture suite requires external language toolchains such as .NET, Java/Maven, Go, Rust, Python/mypy, PHP, Ruby, Kotlin, Scala, and Elixir; it will fail when a required toolchain is unavailable. GitHub Actions uses focused fixture groups configured in `.github/workflows/test-pr.yaml`, for example `QUICKTEST=true FIXTURE=${{ matrix.fixture }} npm run test:fixtures`. + +Formatting and linting use Biome: + +```bash +npm run lint +npm run lint:fix +``` ## Known CI flakiness -Three fixture-CI failure modes are infrastructure flakes, not test or PR bugs: +Three fixture-CI failure modes are infrastructure flakes rather than 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**: fixture setup (`rm -rf elm-stuff && elm make Warmup.elm`) can race the compiler and deadlock on `elm-stuff/*.dat` file locks. +- **cjson**: `cJSON.c` is downloaded from raw.githubusercontent.com at test time and can encounter transient SSL or connection failures. + +The fixture matrix uses `fail-fast: true`, so one flaky job cancels sibling language jobs. The `test-complete` check only mirrors the matrix and is not an independent failure. -- **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. +For now, retry these failed jobs with `gh run rerun --failed`. Treat a failure in one of these areas as real only if it reproduces across retries or the PR touches that area. -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. +## Releasing and version bumps -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. +Do not bump versions in any `package.json` before a release. Package manifest versions are intentionally allowed to be stale in the repository. -## Releasing / version bumps +To publish, create a GitHub Release targeting the commit to release. Stable tags have the form `vMAJOR.MINOR.PATCH` (for example, `v24.0.0`); prerelease tags have the form `vMAJOR.MINOR.PATCH-preN` (for example, `v25.0.0-pre1`). Publishing the release triggers the npm workflow, which publishes stable versions under the `latest` dist-tag and prereleases under `pre`. The VS Code Marketplace workflow runs only for stable tags. -Do not bump versions in any `package.json` before a release. Package manifest -versions are intentionally allowed to be stale in the repository. +Both workflows derive the version exclusively from the release tag and stamp all manifests in the Actions checkout before publishing; those changes are not committed. The release version must be greater than every earlier non-draft GitHub Release with a supported tag. Publication is refused if npm or the VS Code Marketplace already has a newer supported version; an exact version match is skipped, so rerunning a partially completed release is safe. -To publish, create a stable GitHub Release targeting the commit to release and -give it a tag in the form `vMAJOR.MINOR.PATCH`, for example `v24.0.0`. Publishing -the release triggers the npm and VS Code Marketplace workflows. They derive the -version exclusively from the release tag and stamp all manifests in the Actions -checkout before publishing; those changes are not committed. +Test the release-version helper with: -The release version must be greater than every previous stable GitHub Release -and every version already published for the npm packages and VS Code extension. -Rerunning a partially completed release is safe: packages already published at -the exact release version are skipped. +```bash +npm run test:release +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..8b38dffb86 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +We do not accept feature requests as GitHub issues. If you want to add or change functionality, please submit a pull request instead. + +Use the provided issue templates for bug reports and questions. + +The use of coding agents is encouraged, but please use an Opus-class model or stronger rather than a lower-capability model. Review and test all generated changes before submitting them. diff --git a/packages/quicktype-core/src/MakeTransformations.ts b/packages/quicktype-core/src/MakeTransformations.ts index 664a8f7c96..015b81c964 100644 --- a/packages/quicktype-core/src/MakeTransformations.ts +++ b/packages/quicktype-core/src/MakeTransformations.ts @@ -80,8 +80,7 @@ function makeEnumTransformer( stringType: TypeRef, continuation?: Transformer, ): Transformer { - const sortedCases = Array.from(enumType.cases).sort(); - const caseTransformers = sortedCases.map( + const caseTransformers = Array.from(enumType.cases).map( (c) => new StringMatchTransformer( graph, diff --git a/packages/quicktype-core/src/attributes/DefaultValue.ts b/packages/quicktype-core/src/attributes/DefaultValue.ts new file mode 100644 index 0000000000..784e86e0ae --- /dev/null +++ b/packages/quicktype-core/src/attributes/DefaultValue.ts @@ -0,0 +1,87 @@ +import type { + JSONSchemaAttributes, + JSONSchemaType, + Ref, +} from "../input/JSONSchemaInput.js"; +import type { JSONSchema } from "../input/JSONSchemaStore.js"; +import type { Type } from "../Type/Type.js"; + +import { TypeAttributeKind } from "./TypeAttributes.js"; + +export type DefaultValue = boolean | number | string | null; + +class DefaultValueTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("defaultValue"); + } + + public get inIdentity(): boolean { + return true; + } + + public combine(values: DefaultValue[]): DefaultValue | undefined { + const first = values[0]; + return values.every((value) => value === first) ? first : undefined; + } + + public makeInferred(_: DefaultValue): undefined { + return undefined; + } + + public addToSchema( + schema: { [name: string]: unknown }, + _t: Type, + defaultValue: DefaultValue, + ): void { + schema.default = defaultValue; + } + + public stringify(defaultValue: DefaultValue): string { + return JSON.stringify(defaultValue); + } +} + +export const defaultValueTypeAttributeKind: TypeAttributeKind = + new DefaultValueTypeAttributeKind(); + +export function defaultValueAttributeProducer( + schema: JSONSchema, + _ref: Ref, + types: Set, +): JSONSchemaAttributes | undefined { + if (typeof schema !== "object") return undefined; + + const defaultValue = schema.default; + if ( + defaultValue !== null && + typeof defaultValue !== "boolean" && + typeof defaultValue !== "number" && + typeof defaultValue !== "string" + ) { + return undefined; + } + + const attributes = + defaultValueTypeAttributeKind.makeAttributes(defaultValue); + if (typeof defaultValue === "number") { + if (!types.has("number") && !types.has("integer")) return undefined; + return { forNumber: attributes }; + } + + if (typeof defaultValue === "string") { + if (!types.has("string")) return undefined; + return { forString: attributes }; + } + + if (typeof defaultValue === "boolean") { + if (!types.has("boolean")) return undefined; + return { forBoolean: attributes }; + } + + if (!types.has("null")) return undefined; + return { forNull: attributes }; +} + +export function defaultValueForType(t: Type): DefaultValue | undefined { + return defaultValueTypeAttributeKind.tryGetInAttributes(t.getAttributes()); +} diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 88b3a684e0..66b1c4104b 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -29,6 +29,7 @@ import { minMaxLengthAttributeProducer, patternAttributeProducer, } from "../attributes/Constraints.js"; +import { defaultValueAttributeProducer } from "../attributes/DefaultValue.js"; import { descriptionAttributeProducer } from "../attributes/Description.js"; import { enumValuesAttributeProducer } from "../attributes/EnumValues.js"; import { StringTypes } from "../attributes/StringTypes.js"; @@ -651,7 +652,9 @@ const schemaTypes = Object.getOwnPropertyNames( export interface JSONSchemaAttributes { forArray?: TypeAttributes; + forBoolean?: TypeAttributes; forCases?: TypeAttributes[]; + forNull?: TypeAttributes; forNumber?: TypeAttributes; forObject?: TypeAttributes; forString?: TypeAttributes; @@ -1288,6 +1291,12 @@ async function addTypesInSchema( const numberAttributes = combineProducedAttributes( ({ forNumber }) => forNumber, ); + const booleanAttributes = combineProducedAttributes( + ({ forBoolean }) => forBoolean, + ); + const nullAttributes = combineProducedAttributes( + ({ forNull }) => forNull, + ); for (const [name, kind] of [ ["null", "null"], @@ -1299,7 +1308,11 @@ async function addTypesInSchema( const attributes = isNumberTypeKind(kind) ? numberAttributes - : undefined; + : kind === "bool" + ? booleanAttributes + : kind === "null" + ? nullAttributes + : undefined; unionTypes.push(typeBuilder.getPrimitiveType(kind, attributes)); } @@ -1556,6 +1569,7 @@ export class JSONSchemaInput implements Input { this._attributeProducers = [ descriptionAttributeProducer, accessorNamesAttributeProducer, + defaultValueAttributeProducer, enumValuesAttributeProducer, uriSchemaAttributesProducer, minMaxAttributeProducer, diff --git a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts index fb15faf3a6..f87128473a 100644 --- a/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts +++ b/packages/quicktype-core/src/language/CPlusPlus/CPlusPlusRenderer.ts @@ -13,6 +13,7 @@ import { nullTypeIssueAnnotation, } from "../../Annotation.js"; import { getAccessorName } from "../../attributes/AccessorNames.js"; +import { defaultValueForType } from "../../attributes/DefaultValue.js"; import { enumCaseValues } from "../../attributes/EnumValues.js"; import { ConvenienceRenderer, @@ -917,8 +918,41 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { return this._memberNameStyle(`${jsonName}Constraint`); } - protected emitMember(cppType: Sourcelike, name: Sourcelike): void { - this.emitLine(cppType, " ", name, ";"); + protected emitMember( + cppType: Sourcelike, + name: Sourcelike, + defaultValue?: Sourcelike, + ): void { + this.emitLine( + cppType, + " ", + name, + defaultValue === undefined ? [] : [" = ", defaultValue], + ";", + ); + } + + protected cppDefaultValue(t: Type): Sourcelike | undefined { + const defaultValue = defaultValueForType(t); + if (defaultValue === undefined) return undefined; + + if (defaultValue === null) return "nullptr"; + if (typeof defaultValue === "boolean") { + return defaultValue ? "true" : "false"; + } + + if (typeof defaultValue === "number") return String(defaultValue); + if (t instanceof EnumType && t.cases.has(defaultValue)) { + return [ + this.nameForNamedType(t), + "::", + this.nameForEnumCase(t, defaultValue), + ]; + } + + return this._stringType.createStringLiteral([ + stringEscape(defaultValue), + ]); } protected emitClassMembers( @@ -942,6 +976,7 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { property.isOptional, ), name, + this.cppDefaultValue(property.type), ); if (constraints?.has(jsonName)) { /** FIXME!!! NameStyle will/can collide with other Names */ @@ -972,6 +1007,7 @@ export class CPlusPlusRenderer extends ConvenienceRenderer { property.isOptional, ), name, + this.cppDefaultValue(property.type), ); } else { const [getterName, mutableGetterName, setterName] = defined( diff --git a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts index 960ed47820..6b5293969c 100644 --- a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts @@ -62,6 +62,13 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { private readonly _needNamespaces: boolean; + private get needConverterClass(): boolean { + return ( + this._needHelpers || + (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) + ); + } + public constructor( targetLanguage: TargetLanguage, renderContext: RenderContext, @@ -160,12 +167,14 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { super.emitUsings(); this.ensureBlankLine(); - for (const ns of [ - "System.Globalization", - "Newtonsoft.Json", - "Newtonsoft.Json.Converters", - ]) { - this.emitUsing(ns); + if (this.needConverterClass) { + this.emitUsing("System.Globalization"); + } + + this.emitUsing("Newtonsoft.Json"); + + if (this.needConverterClass) { + this.emitUsing("Newtonsoft.Json.Converters"); } if (this._options.dense) { @@ -1177,10 +1186,7 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { this.emitSerializeClass(); } - if ( - this._needHelpers || - (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) - ) { + if (this.needConverterClass) { this.ensureBlankLine(); this.emitConverterClass(); this.forEachTransformation("leading-and-interposing", (n, t) => diff --git a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts index 72a741c9ca..7862f02bda 100644 --- a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts @@ -61,6 +61,13 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { private readonly _needNamespaces: boolean; + private get needConverterClass(): boolean { + return ( + this._needHelpers || + (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) + ); + } + public constructor( targetLanguage: TargetLanguage, renderContext: RenderContext, @@ -165,11 +172,14 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { for (const ns of [ "System.Text.Json", "System.Text.Json.Serialization", - "System.Globalization", ]) { this.emitUsing(ns); } + if (this.needConverterClass) { + this.emitUsing("System.Globalization"); + } + if (this._options.dense) { this.emitUsing([ denseJsonPropertyName, @@ -1302,10 +1312,7 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { this.emitSerializeClass(); } - if ( - this._needHelpers || - (this._needAttributes && (this.haveNamedUnions || this.haveEnums)) - ) { + if (this.needConverterClass) { this.ensureBlankLine(); this.emitConverterClass(); this.forEachTransformation("leading-and-interposing", (n, t) => diff --git a/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts b/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts index df24ab19ef..728fb886d8 100644 --- a/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts +++ b/packages/quicktype-core/src/language/Haskell/HaskellRenderer.ts @@ -4,7 +4,7 @@ import { ConvenienceRenderer, type ForbiddenWordsInfo, } from "../../ConvenienceRenderer.js"; -import type { Name, Namer } from "../../Naming.js"; +import { DependencyName, type Name, type Namer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; import type { OptionValues } from "../../RendererOptions/index.js"; import { @@ -73,6 +73,26 @@ export class HaskellRenderer extends ConvenienceRenderer { return true; } + protected makeNameForEnumCase( + e: EnumType, + enumName: Name, + caseName: string, + assignedName: string | undefined, + ): Name { + const name = super.makeNameForEnumCase( + e, + enumName, + caseName, + assignedName, + ); + const proposedName = name.firstProposedName(new Map()); + return new DependencyName( + name.namingFunction, + name.order, + (lookup) => `${proposedName}_${lookup(enumName)}`, + ); + } + protected proposeUnionMemberName( u: UnionType, unionName: Name, @@ -109,7 +129,7 @@ export class HaskellRenderer extends ConvenienceRenderer { private haskellType(t: Type, noOptional = false): MultiWord { return matchType( t, - (_anyType) => multiWord(" ", "Maybe", "Text"), + (_anyType) => singleWord("Value"), (_nullType) => multiWord(" ", "Maybe", "Text"), (_boolType) => singleWord("Bool"), (_integerType) => singleWord("Int"), @@ -226,6 +246,10 @@ export class HaskellRenderer extends ConvenienceRenderer { }); } + private enumCaseName(name: Name, enumName: Name): Sourcelike { + return name instanceof DependencyName ? name : [name, enumName]; + } + private emitEnumDefinition(e: EnumType, enumName: Name): void { this.emitDescription(this.descriptionForType(e)); this.emitLine("data ", enumName); @@ -233,7 +257,11 @@ export class HaskellRenderer extends ConvenienceRenderer { let onFirst = true; this.forEachEnumCase(e, "none", (name) => { const equalsOrPipe = onFirst ? "=" : "|"; - this.emitLine(equalsOrPipe, " ", name, enumName); + this.emitLine( + equalsOrPipe, + " ", + this.enumCaseName(name, enumName), + ); onFirst = false; }); this.emitLine("deriving (Show)"); @@ -355,8 +383,7 @@ export class HaskellRenderer extends ConvenienceRenderer { this.forEachEnumCase(e, "none", (name, jsonName) => { this.emitLine( "toJSON ", - name, - enumName, + this.enumCaseName(name, enumName), ' = "', stringEscape(jsonName), '"', @@ -377,8 +404,7 @@ export class HaskellRenderer extends ConvenienceRenderer { 'parseText "', stringEscape(jsonName), '" = return ', - name, - enumName, + this.enumCaseName(name, enumName), ); }); }); diff --git a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts index def7a00ee1..3d84e51107 100644 --- a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts +++ b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts @@ -555,12 +555,22 @@ function r(name${stringAnnotation}) { this.ensureBlankLine(); this.emitBlock("module.exports = ", ";", () => { - this.forEachTopLevel("none", (_, name) => { + const exporter = (_: Type, name: Name): void => { const serializer = this.serializerFunctionName(name); const deserializer = this.deserializerFunctionName(name); this.emitLine('"', serializer, '": ', serializer, ","); this.emitLine('"', deserializer, '": ', deserializer, ","); - }); + }; + + switch (this._jsOptions.converters) { + case ConvertersOptions.AllObjects: + this.forEachObject("none", exporter); + break; + + default: + this.forEachTopLevel("none", exporter); + break; + } }); } diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index 963b68b54a..e79d83e124 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -57,9 +57,9 @@ export class KotlinRenderer extends ConvenienceRenderer { protected forbiddenForEnumCases( _e: EnumType, - _enumName: Name, + enumName: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + return { names: [enumName], includeGlobalForbidden: true }; } protected forbiddenForUnionMembers( diff --git a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts index d6b2b95e11..7f0c0b2e47 100644 --- a/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts +++ b/packages/quicktype-core/src/language/Swift/SwiftRenderer.ts @@ -1186,14 +1186,9 @@ encoder.dateEncodingStrategy = .formatted(formatter)`); }`); if (this._options.objcSupport === false) { - this.ensureBlankLine(); - this.emitMultiline(` public var hashValue: Int { - return 0 - }`); - this.ensureBlankLine(); this.emitMultiline(` public func hash(into hasher: inout Hasher) { - // No-op + hasher.combine(0) }`); } diff --git a/packages/quicktype-core/src/language/Swift/utils.ts b/packages/quicktype-core/src/language/Swift/utils.ts index 5c068a5a05..228ddeae14 100644 --- a/packages/quicktype-core/src/language/Swift/utils.ts +++ b/packages/quicktype-core/src/language/Swift/utils.ts @@ -69,7 +69,7 @@ export function swiftNameStyle( legalizeName, isUpper ? firstUpperWordStyle : allLowerWordStyle, firstUpperWordStyle, - isUpper ? allUpperWordStyle : allLowerWordStyle, + isUpper ? acronymsStyle : allLowerWordStyle, acronymsStyle, "", isStartCharacter, diff --git a/packages/quicktype-typescript-input/src/index.ts b/packages/quicktype-typescript-input/src/index.ts index 38dfdf4d0e..e93f7c0af3 100644 --- a/packages/quicktype-typescript-input/src/index.ts +++ b/packages/quicktype-typescript-input/src/index.ts @@ -126,6 +126,55 @@ function patchGeneratorForBuiltinTypes( }; } +function addEnumAccessorNames(schema: Definition, program: ts.Program): void { + const definitions = schema.definitions; + if (definitions === undefined) return; + + const checker = program.getTypeChecker(); + const visit = (node: ts.Node): void => { + if (ts.isEnumDeclaration(node)) { + const symbol = checker.getSymbolAtLocation(node.name); + if ( + symbol !== undefined && + !isDeclaredInDefaultLib(program, symbol) + ) { + const accessorNames: Record = + Object.create(null); + for (const member of node.members) { + const value = checker.getConstantValue(member); + const name = member.name; + if ( + typeof value !== "string" || + (!ts.isIdentifier(name) && + !ts.isStringLiteral(name) && + !ts.isNumericLiteral(name)) + ) { + return; + } + + accessorNames[value] = name.text; + } + + const definitionName = checker + .getFullyQualifiedName(symbol) + .replace(/(\bimport\(".*?"\)|".*?")\.| /g, ""); + const definition = definitions[definitionName]; + if (typeof definition === "object") { + (definition as unknown as Record)[ + "qt-accessors" + ] = accessorNames; + } + } + } + + ts.forEachChild(node, visit); + }; + + for (const sourceFile of program.getSourceFiles()) { + visit(sourceFile); + } +} + // 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. @@ -176,6 +225,10 @@ export function schemaForTypeScriptSources( patchGeneratorForBuiltinTypes(generator, program); const schema = generateSchema(program, "*", settings, undefined, generator); + if (schema !== null) { + addEnumAccessorNames(schema, program); + } + sanitizeTypeAnnotations(schema); const uris: string[] = []; diff --git a/test/fixtures.ts b/test/fixtures.ts index 1ecbfabbeb..1be4f8ee19 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -698,6 +698,7 @@ const skipTypeScriptTests = [ "optional-union.json", "pokedex.json", // Enums are screwed up: https://github.com/YousefED/typescript-json-schema/issues/186 "github-events.json", + "kotlin-enum-class-case-collision.json", "bug855-short.json", "bug863.json", "00c36.json", diff --git a/test/inputs/json/priority/kotlin-enum-class-case-collision.json b/test/inputs/json/priority/kotlin-enum-class-case-collision.json new file mode 100644 index 0000000000..6fe2377c14 --- /dev/null +++ b/test/inputs/json/priority/kotlin-enum-class-case-collision.json @@ -0,0 +1,22 @@ +[ + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" }, + { "category": " " }, + { "category": "" } +] diff --git a/test/inputs/schema/any.1.json b/test/inputs/schema/any.1.json index 6465e11c40..697eb545b5 100644 --- a/test/inputs/schema/any.1.json +++ b/test/inputs/schema/any.1.json @@ -1 +1,8 @@ -{ "foo": 123 } +{ + "foo": 123, + "values": { + "number": 42, + "object": { "a": 1 }, + "array": [1, 2, 3] + } +} diff --git a/test/inputs/schema/any.2.json b/test/inputs/schema/any.2.json index 2ff04ea90e..5a35b97628 100644 --- a/test/inputs/schema/any.2.json +++ b/test/inputs/schema/any.2.json @@ -1 +1,7 @@ -{ "foo": [1, 2, 3] } +{ + "foo": [1, 2, 3], + "values": { + "boolean": true, + "null": null + } +} diff --git a/test/inputs/schema/any.3.json b/test/inputs/schema/any.3.json index fefd2e6dea..9fb43de4a2 100644 --- a/test/inputs/schema/any.3.json +++ b/test/inputs/schema/any.3.json @@ -1 +1,6 @@ -{ "foo": { "bar": "abc" } } +{ + "foo": { "bar": "abc" }, + "values": { + "string": "text" + } +} diff --git a/test/inputs/schema/any.schema b/test/inputs/schema/any.schema index a7067ed754..166aa4aaf2 100644 --- a/test/inputs/schema/any.schema +++ b/test/inputs/schema/any.schema @@ -1,7 +1,11 @@ { "type": "object", "properties": { - "foo": {} + "foo": {}, + "values": { + "type": "object", + "additionalProperties": true + } }, - "required": ["foo"] + "required": ["foo", "values"] } diff --git a/test/inputs/schema/default-value.1.fail.no-defaults.json b/test/inputs/schema/default-value.1.fail.no-defaults.json new file mode 100644 index 0000000000..2c63c08510 --- /dev/null +++ b/test/inputs/schema/default-value.1.fail.no-defaults.json @@ -0,0 +1,2 @@ +{ +} diff --git a/test/inputs/schema/default-value.1.json b/test/inputs/schema/default-value.1.json new file mode 100644 index 0000000000..8bf2491e04 --- /dev/null +++ b/test/inputs/schema/default-value.1.json @@ -0,0 +1,3 @@ +{ + "id": 0 +} diff --git a/test/inputs/schema/default-value.schema b/test/inputs/schema/default-value.schema new file mode 100644 index 0000000000..02aecfa888 --- /dev/null +++ b/test/inputs/schema/default-value.schema @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "An object with a scalar default", + "type": "object", + "properties": { + "id": { + "type": "integer", + "default": 0 + } + }, + "required": ["id"], + "additionalProperties": false +} diff --git a/test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json b/test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json new file mode 100644 index 0000000000..d38b6a8168 --- /dev/null +++ b/test/inputs/schema/haskell-enum-forbidden.1.fail.enum.json @@ -0,0 +1,3 @@ +{ + "health": "warning" +} diff --git a/test/inputs/schema/haskell-enum-forbidden.1.json b/test/inputs/schema/haskell-enum-forbidden.1.json new file mode 100644 index 0000000000..1513721d9a --- /dev/null +++ b/test/inputs/schema/haskell-enum-forbidden.1.json @@ -0,0 +1,3 @@ +{ + "health": "error" +} diff --git a/test/inputs/schema/haskell-enum-forbidden.schema b/test/inputs/schema/haskell-enum-forbidden.schema new file mode 100644 index 0000000000..c06f971a56 --- /dev/null +++ b/test/inputs/schema/haskell-enum-forbidden.schema @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": { + "health": { + "type": "string", + "enum": ["ok", "error"] + } + }, + "required": ["health"] +} diff --git a/test/languages.ts b/test/languages.ts index 4e2cf42083..d413a66bcd 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -25,6 +25,7 @@ const skipsEnumValueValidation = [ "enum-large.schema", "optional-enum.schema", "const-non-string.schema", + "haskell-enum-forbidden.schema", "nullable-optional-one-of.schema", "all-of-additional-properties-false.schema", ]; @@ -648,6 +649,9 @@ export const CJSONLanguage: Language = { /* Required properties absent are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ "intersection.schema", "required.schema", + // The default-value fail sample also relies on required-property + // enforcement, which cJSON does not do. + "default-value.schema", /* Pure Any type not supported (for the current implementation, can be added later, should manage a callback to provide the final application a way to handle it at parsing and creation of cJSON) */ "any.schema", "direct-union.schema", @@ -969,6 +973,8 @@ export const SwiftLanguage: Language = { // This works on macOS, but on Linux one of the failure test cases doesn't fail ...skipsUntypedUnions, "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "multi-type-enum.schema", "intersection.schema", ...skipsMapValueValidation, @@ -1138,6 +1144,7 @@ export const JavaScriptLanguage: Language = { { "runtime-typecheck": "false" }, { "runtime-typecheck-ignore-unknown-properties": "true" }, { converters: "top-level" }, + ["nested-objects.json", { converters: "all-objects" }], ], sourceFiles: ["src/language/JavaScript/index.ts"], }; @@ -1584,6 +1591,7 @@ export const KotlinXLanguage: Language = { // Top-level arrays render as `typealias TopLevel = JsonArray`, // which doesn't compile — kotlinx's JsonArray takes no type // arguments (documented TODO in KotlinXRenderer.ts). + "kotlin-enum-class-case-collision.json", "bug863.json", "github-events.json", "optional-union.json", @@ -1882,7 +1890,6 @@ 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 // "null" and exits 0 — expected-failure samples cannot be detected. @@ -1897,6 +1904,8 @@ export const HaskellLanguage: Language = { "keyword-unions.schema", "optional-any.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2067,6 +2076,8 @@ export const TypeScriptZodLanguage: Language = { "optional-any.schema", "recursive-union-flattening.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2183,6 +2194,8 @@ export const TypeScriptEffectSchemaLanguage: Language = { "keyword-unions.schema", "optional-any.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "required-non-properties.schema", ], rendererOptions: {}, @@ -2241,6 +2254,8 @@ export const ElixirLanguage: Language = { // Struct keys cannot be enforced at runtime in Elixir and their values will just be set to null. "strict-optional.schema", "required.schema", + // The default-value fail sample also relies on required-property enforcement. + "default-value.schema", "boolean-subschema.schema", "intersection.schema", "optional-any.schema", diff --git a/test/unit/csharp-superfluous-usings.test.ts b/test/unit/csharp-superfluous-usings.test.ts new file mode 100644 index 0000000000..999d2acfae --- /dev/null +++ b/test/unit/csharp-superfluous-usings.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; + +import { + InputData, + type RendererOptions, + jsonInputForTargetLanguage, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; + +async function renderCSharp(rendererOptions: RendererOptions): Promise { + const jsonInput = jsonInputForTargetLanguage("csharp"); + await jsonInput.addSource({ + name: "Sample", + samples: ['{"name":"Alice","age":30,"active":true}'], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ + inputData, + lang: "csharp", + rendererOptions, + }); + return result.lines.join("\n"); +} + +describe("C# using statements", () => { + test("NewtonSoft attributes-only omits converter usings when no converter is emitted", async () => { + const output = await renderCSharp({ + framework: "NewtonSoft", + features: "attributes-only", + }); + + expect(output).toContain("using Newtonsoft.Json;"); + expect(output).not.toContain("using System.Globalization;"); + expect(output).not.toContain("using Newtonsoft.Json.Converters;"); + }); + + test("SystemTextJson attributes-only omits globalization when no converter is emitted", async () => { + const output = await renderCSharp({ + framework: "SystemTextJson", + features: "attributes-only", + }); + + expect(output).toContain("using System.Text.Json.Serialization;"); + expect(output).not.toContain("using System.Globalization;"); + }); + + test.each([ + ["NewtonSoft", "using Newtonsoft.Json.Converters;"], + ["SystemTextJson", "using System.Globalization;"], + ] as Array< + ["NewtonSoft" | "SystemTextJson", string] + >)("%s complete output keeps converter usings", async (framework, expectedUsing) => { + const output = await renderCSharp({ framework }); + + expect(output).toContain("using System.Globalization;"); + expect(output).toContain(expectedUsing); + expect(output).toContain("IsoDateTime"); + }); +}); diff --git a/test/unit/enum-order.test.ts b/test/unit/enum-order.test.ts index da930ac15c..deaade5b47 100644 --- a/test/unit/enum-order.test.ts +++ b/test/unit/enum-order.test.ts @@ -1,23 +1,39 @@ -import { - InputData, - JSONSchemaInput, - quicktype, -} from "../../packages/quicktype-core/src/index.js"; +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; import { expect, test } from "vitest"; -const schema = JSON.stringify({ - $schema: "http://json-schema.org/draft-04/schema#", - title: "Test", - type: "object", - properties: { - errorCode: { +test("preserves JSON Schema enum case order (C#)", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "DaySchema", + schema: JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", type: "string", - enum: ["B", "A", "E"], - }, - }, + enum: ["Monday", "Tuesday", "Friday", "Sunday"], + }), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "csharp" }); + + expect(result.lines.join("\n")).toContain( + "public enum DaySchemaEnum { Monday, Tuesday, Friday, Sunday };", + ); }); -test("preserves JSON Schema enum case order", async () => { +test("preserves JSON Schema enum case order (C++)", async () => { + const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-04/schema#", + title: "Test", + type: "object", + properties: { + errorCode: { + type: "string", + enum: ["B", "A", "E"], + }, + }, + }); + const schemaInput = new JSONSchemaInput(undefined); await schemaInput.addSource({ name: "Test", schema }); diff --git a/test/unit/haskell-enum-case-naming.test.ts b/test/unit/haskell-enum-case-naming.test.ts new file mode 100644 index 0000000000..72354e7627 --- /dev/null +++ b/test/unit/haskell-enum-case-naming.test.ts @@ -0,0 +1,75 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function renderHaskell(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: "haskell", + }); + return result.lines.join("\n"); +} + +function enumConstructors(output: string): string[] { + // Collect the constructors of the generated `data Health` declaration, + // which are emitted one per line as `= OkHealth` / `| ErrorHealth`. + const lines = output.split("\n"); + const start = lines.findIndex((line) => line.startsWith("data Health")); + if (start < 0) { + return []; + } + + const constructors: string[] = []; + for (const line of lines.slice(start + 1)) { + const match = line.match(/^\s*[=|]\s+(\S+)/); + if (match === null) { + break; + } + + constructors.push(match[1]); + } + + return constructors; +} + +describe("Haskell enum case naming", () => { + // Enum cases are emitted as `` so the enum name already + // disambiguates each constructor. When a case name would otherwise be + // renamed to avoid a forbidden identifier (here `error`), the renderer + // must not compound the enum-name suffix on top of that rename. A + // round-trip fixture cannot catch this: the constructor name is invisible + // to JSON serialization, so both the correct and the over-renamed output + // round-trip identically. See issue #2868. + test("suffixes the enum name without over-renaming forbidden cases", async () => { + const schema = { + type: "object", + properties: { + health: { + type: "string", + enum: ["ok", "error"], + }, + }, + required: ["health"], + }; + + const output = await renderHaskell(schema); + const constructors = enumConstructors(output); + + expect(constructors).toEqual(["OkHealth", "ErrorHealth"]); + // Guard against the specific regression: the "error" case being + // renamed to "HealthError" before the enum suffix is appended. + expect(output).not.toContain("HealthErrorHealth"); + }); +}); diff --git a/test/unit/javascript-all-objects-exports.test.ts b/test/unit/javascript-all-objects-exports.test.ts new file mode 100644 index 0000000000..7950c6b886 --- /dev/null +++ b/test/unit/javascript-all-objects-exports.test.ts @@ -0,0 +1,85 @@ +// Regression test for #1655. With `--converters all-objects` the JavaScript +// renderer emits a `to`/`ToJson` converter pair for every object +// type, not just the top-level ones. Before the fix, `emitModuleExports()` +// still iterated only the top-level types, so those extra per-object +// converters were generated in the module body but never listed in +// `module.exports` — callers of the module could not reach them. +// +// A round-trip fixture cannot catch this: the driver deserializes and +// reserializes through the top-level converter, which is exported and works +// regardless of whether the nested converters are. This test therefore +// generates the module directly and inspects its `module.exports` block. + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +// A schema with a nested object (`data123`) so the all-objects renderer emits a +// converter for a non-top-level type. +const sample = JSON.stringify({ data123: { name: "quicktype" } }); + +async function renderJavaScript(converters: string): Promise { + const jsonInput = jsonInputForTargetLanguage("js"); + await jsonInput.addSource({ name: "TopLevel", samples: [sample] }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + + const result = await quicktype({ + inputData, + lang: "js", + rendererOptions: { "acronym-style": "pascal", converters }, + }); + return result.lines.join("\n"); +} + +// The `module.exports = { ... };` object literal at the bottom of the module. +function moduleExportsBlock(source: string): string { + const match = source.match(/module\.exports\s*=\s*\{([\s\S]*?)\};/); + // biome-ignore lint/suspicious/noMisplacedAssertion: helper is only called from within tests + expect(match, `no module.exports block in:\n${source}`).not.toBeNull(); + return match?.[1] ?? ""; +} + +// Every converter the module defines — `function to(json)` and +// `function ToJson(value)`. The module's helpers (cast, uncast, +// transform, …) have different signatures and are intentionally not matched. +function definedConverters(source: string): string[] { + return [ + ...source.matchAll(/^function (to[A-Z]\w*)\(json\)/gm), + ...source.matchAll(/^function (\w+ToJson)\(value\)/gm), + ].map((m) => m[1]); +} + +describe("JavaScript converters: all-objects module.exports", () => { + test("exports a converter for every object type, including nested ones", async () => { + const source = await renderJavaScript("all-objects"); + const converters = definedConverters(source); + const exportsBlock = moduleExportsBlock(source); + + // Sanity: the renderer actually emitted a nested-object converter. + expect(converters).toContain("toData123"); + expect(converters).toContain("data123ToJson"); + + // The invariant #1655 restores: every defined converter is exported. + for (const name of converters) { + expect( + exportsBlock, + `converter ${name} is defined but not in module.exports`, + ).toContain(name); + } + }); + + test("top-level mode exports only top-level converters", async () => { + const source = await renderJavaScript("top-level"); + const exportsBlock = moduleExportsBlock(source); + + expect(exportsBlock).toContain("toTopLevel"); + // The nested converter is not generated in top-level mode, so it must + // not appear in the exports either — this is what distinguishes the + // all-objects behavior above from the default. + expect(exportsBlock).not.toContain("toData123"); + }); +}); diff --git a/test/unit/just-types-option.test.ts b/test/unit/just-types-option.test.ts index cac04a5e15..d6c9832bd2 100644 --- a/test/unit/just-types-option.test.ts +++ b/test/unit/just-types-option.test.ts @@ -90,7 +90,7 @@ describe("just-types generates plain types in every language", () => { expect(output).toContain("enum class CanvasAction(val value: String)"); expect(output).toContain('Add("add")'); - expect(output).toContain('Bringtofront("bringtofront")'); + expect(output).toContain('BringToFront("bringtofront")'); expect(output).toContain( "fun fromValue(value: String): CanvasAction = when (value)", ); diff --git a/test/unit/swift-acronym-names.test.ts b/test/unit/swift-acronym-names.test.ts new file mode 100644 index 0000000000..bc9310510b --- /dev/null +++ b/test/unit/swift-acronym-names.test.ts @@ -0,0 +1,45 @@ +// The fixture harness cannot catch acronym casing in Swift type names: the +// generated identifiers are self-consistent, so the code still compiles and +// round-trips JSON successfully. Generate Swift directly and assert on the +// emitted struct declaration instead. + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { describe, expect, test } from "vitest"; + +async function swiftStructName(acronymStyle: string): Promise { + const jsonInput = jsonInputForTargetLanguage("swift"); + await jsonInput.addSource({ + name: "FaqCoordinate", + samples: ['{"x":1,"y":2}'], + }); + + const inputData = new InputData(); + inputData.addInput(jsonInput); + + const result = await quicktype({ + inputData, + lang: "swift", + rendererOptions: { "acronym-style": acronymStyle }, + }); + const output = result.lines.join("\n"); + const match = output.match(/^struct (\w+): Codable \{$/m); + + // biome-ignore lint/suspicious/noMisplacedAssertion: helper is only called from within tests + expect(match, `generated Swift output:\n${output}`).not.toBeNull(); + return match?.[1] ?? ""; +} + +describe("Swift leading acronym casing", () => { + test.each([ + ["original", "FaqCoordinate"], + ["pascal", "FAQCoordinate"], + ["camel", "FaqCoordinate"], + ["lowerCase", "faqCoordinate"], + ])("honors acronym-style=%s for struct names", async (acronymStyle, expectedName) => { + await expect(swiftStructName(acronymStyle)).resolves.toBe(expectedName); + }); +}); diff --git a/test/unit/swift-json-null-hashable.test.ts b/test/unit/swift-json-null-hashable.test.ts index 78c9cf9288..d8013b9df3 100644 --- a/test/unit/swift-json-null-hashable.test.ts +++ b/test/unit/swift-json-null-hashable.test.ts @@ -13,7 +13,7 @@ const schema = JSON.stringify({ required: ["value"], }); -test("emits hash(into:) for JSONNull by default", async () => { +test("emits modern Hashable implementation for JSONNull by default", async () => { const schemaInput = new JSONSchemaInput(undefined); await schemaInput.addSource({ name: "TopLevel", schema }); @@ -24,4 +24,6 @@ test("emits hash(into:) for JSONNull by default", async () => { const output = result.lines.join("\n"); expect(output).toContain("public func hash(into hasher: inout Hasher)"); + expect(output).toContain("hasher.combine(0)"); + expect(output).not.toContain("public var hashValue: Int"); }); diff --git a/test/unit/typescript-input.test.ts b/test/unit/typescript-input.test.ts index ca060f558f..ce7585ef56 100644 --- a/test/unit/typescript-input.test.ts +++ b/test/unit/typescript-input.test.ts @@ -1,7 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; import { schemaForTypeScriptSources } from "quicktype-typescript-input"; import { afterAll, describe, expect, test } from "vitest"; @@ -48,6 +53,20 @@ async function swiftForSource(source: string): Promise { return result.lines.join("\n"); } +async function generateTypeScriptForSource(source: string): Promise { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource(schemaSourceForSource(source)); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true, "prefer-unions": false }, + }); + return result.lines.join("\n"); +} + describe("schemaForTypeScriptSources", () => { test("preserves class property declaration order in generated output", async () => { const output = await swiftForSource(` @@ -146,6 +165,37 @@ describe("schemaForTypeScriptSources", () => { expect(config.properties.count.default).toBe(42); }); + test("string enums preserve their member names in the schema", () => { + const { schema } = schemaForSource(` + export enum TestEnum { + Foo = "000", + Bar = "001", + Baz = "002", + } + `); + + expect(schema.definitions.TestEnum["qt-accessors"]).toEqual({ + "000": "Foo", + "001": "Bar", + "002": "Baz", + }); + }); + + test("string enum member names survive through TypeScript output", async () => { + const output = await generateTypeScriptForSource(` + export enum TestEnum { + Foo = "000", + Bar = "001", + Baz = "002", + } + `); + + expect(output).toContain('Foo = "000"'); + expect(output).toContain('Bar = "001"'); + expect(output).toContain('Baz = "002"'); + expect(output).not.toMatch(/The00[0-2]/); + }); + // The previously used fork of typescript-json-schema threw // "Unsupported type: bigint" here. test("bigint properties are converted to numbers", () => {