From d7943816116a2721c78d6b262df5c94345773d3c Mon Sep 17 00:00:00 2001 From: "jonas.hao" Date: Mon, 23 Jun 2025 23:34:03 +0800 Subject: [PATCH 1/3] feat(Rust): Add integer type inference option 1. Added IntegerType enum 2. Introduced integerType configuration option - Supports forced i32/i64 usage - Enables automatic selection based on numerical range --- .../src/language/Rust/RustRenderer.ts | 31 ++++++++++++- .../src/language/Rust/language.ts | 16 +++++++ test/inputs/schema/integer-type.1.json | 8 ++++ test/inputs/schema/integer-type.schema | 43 +++++++++++++++++++ test/languages.ts | 5 +++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 test/inputs/schema/integer-type.1.json create mode 100644 test/inputs/schema/integer-type.schema diff --git a/packages/quicktype-core/src/language/Rust/RustRenderer.ts b/packages/quicktype-core/src/language/Rust/RustRenderer.ts index 22e81babca..052a4a6a1c 100644 --- a/packages/quicktype-core/src/language/Rust/RustRenderer.ts +++ b/packages/quicktype-core/src/language/Rust/RustRenderer.ts @@ -27,8 +27,10 @@ import { removeNullFromUnion, } from "../../Type/TypeUtils"; +import { minMaxValueForType } from "../../attributes/Constraints"; import { keywords } from "./constants"; import type { rustOptions } from "./language"; +import { IntegerType } from "./language"; import { Density, type NamingStyleKey, @@ -96,6 +98,33 @@ 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"; + case IntegerType.Conservative: + default: { + const minMax = minMaxValueForType(integerType); + if (minMax !== undefined) { + const [min, max] = minMax; + // Check if values fit in i32 range: [-2147483648, 2147483647] + const i32Min = -2147483648; + const i32Max = 2147483647; + + if ( + (min === undefined || min >= i32Min) && + (max === undefined || max <= i32Max) + ) { + return "i32"; + } + } + return "i64"; + } + } + } + private nullableRustType(t: Type, withIssues: boolean): Sourcelike { return ["Option<", this.breakCycle(t, withIssues), ">"]; } @@ -121,7 +150,7 @@ export class RustRenderer extends ConvenienceRenderer { "Option", ), (_boolType) => "bool", - (_integerType) => "i64", + (integerType) => this.getIntegerType(integerType), (_doubleType) => "f64", (_stringType) => "String", (arrayType) => [ diff --git a/packages/quicktype-core/src/language/Rust/language.ts b/packages/quicktype-core/src/language/Rust/language.ts index 4981d6a6f8..60abb790e4 100644 --- a/packages/quicktype-core/src/language/Rust/language.ts +++ b/packages/quicktype-core/src/language/Rust/language.ts @@ -10,6 +10,12 @@ import type { LanguageName, RendererOptions } from "../../types"; import { RustRenderer } from "./RustRenderer"; import { Density, Visibility } from "./utils"; +export enum IntegerType { + Conservative = "conservative", + ForceI32 = "force-i32", + ForceI64 = "force-i64", +} + export const rustOptions = { density: new EnumOption( "density", @@ -30,6 +36,16 @@ export const rustOptions = { } as const, "private", ), + 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", false), deriveClone: new BooleanOption("derive-clone", "Derive Clone impl", false), derivePartialEq: new BooleanOption( diff --git a/test/inputs/schema/integer-type.1.json b/test/inputs/schema/integer-type.1.json new file mode 100644 index 0000000000..eb9b681430 --- /dev/null +++ b/test/inputs/schema/integer-type.1.json @@ -0,0 +1,8 @@ +{ + "small_positive": 50, + "large_positive": 2500000000, + "small_negative": -50, + "large_negative": -2500000000, + "i32_range": 1000000000, + "beyond_i32": 9000000000000000000 +} diff --git a/test/inputs/schema/integer-type.schema b/test/inputs/schema/integer-type.schema new file mode 100644 index 0000000000..b096a3443d --- /dev/null +++ b/test/inputs/schema/integer-type.schema @@ -0,0 +1,43 @@ +{ + "type": "object", + "properties": { + "small_positive": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "large_positive": { + "type": "integer", + "minimum": 0, + "maximum": 5000000000 + }, + "small_negative": { + "type": "integer", + "minimum": -100, + "maximum": 0 + }, + "large_negative": { + "type": "integer", + "minimum": -5000000000, + "maximum": 0 + }, + "i32_range": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "beyond_i32": { + "type": "integer", + "minimum": -9223372036854775808, + "maximum": 9223372036854775807 + } + }, + "required": [ + "small_positive", + "large_positive", + "small_negative", + "large_negative", + "i32_range", + "beyond_i32" + ] +} diff --git a/test/languages.ts b/test/languages.ts index 9573f3e574..ee795909f0 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -260,6 +260,9 @@ export const RustLanguage: Language = { { visibility: "crate" }, { visibility: "private" }, { visibility: "public" }, + { "integer-type": "conservative" }, + { "integer-type": "force-i32" }, + { "integer-type": "force-i64" }, ], sourceFiles: ["src/language/Rust/index.ts"], }; @@ -584,6 +587,8 @@ export const CPlusPlusLanguage: Language = { skipSchema: [ // uses too much memory "keyword-unions.schema", + // seems like a problem with integer range check + "integer-type.schema", ], rendererOptions: {}, quickTestRendererOptions: [ From 1ddaf28abf783d0ec5724faa5604640553820cab Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 12:17:26 -0400 Subject: [PATCH 2/3] fix(rust): require both bounds for conservative i32 inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conservative integer-type mode treated a one-sided schema bound as fitting i32, so `{"type": "integer", "minimum": 0}` — unbounded above — rendered as `i32`, an overflow hazard. Conservative now picks i32 only when the schema bounds the integer on both sides and both bounds fit the i32 range. Also override `getSupportedIntegerRange` so that with `force-i32`, whole numbers in input JSON outside the i32 range are inferred as `double` instead of an integer type that cannot round-trip them, matching how cJSON's `integer-size` option hooks into the integer range machinery from #2931. Co-Authored-By: Claude Fable 5 --- .../src/language/Rust/RustRenderer.ts | 27 ++++++++----------- .../src/language/Rust/language.ts | 26 ++++++++++++++++++ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/quicktype-core/src/language/Rust/RustRenderer.ts b/packages/quicktype-core/src/language/Rust/RustRenderer.ts index 7bb7df6d37..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, @@ -27,7 +28,6 @@ import { removeNullFromUnion, } from "../../Type/TypeUtils.js"; -import { minMaxValueForType } from "../../attributes/Constraints.js"; import { keywords } from "./constants.js"; import { IntegerType, type rustOptions } from "./language.js"; import { @@ -103,23 +103,18 @@ export class RustRenderer extends ConvenienceRenderer { return "i32"; case IntegerType.ForceI64: return "i64"; - case IntegerType.Conservative: 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) { - const [min, max] = minMax; - // Check if values fit in i32 range: [-2147483648, 2147483647] - const i32Min = -2147483648; - const i32Max = 2147483647; - - if ( - (min === undefined || min >= i32Min) && - (max === undefined || max <= i32Max) - ) { - return "i32"; - } - } - return "i64"; + 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"; } } } diff --git a/packages/quicktype-core/src/language/Rust/language.ts b/packages/quicktype-core/src/language/Rust/language.ts index 94312fcea1..ce25cf9408 100644 --- a/packages/quicktype-core/src/language/Rust/language.ts +++ b/packages/quicktype-core/src/language/Rust/language.ts @@ -4,6 +4,11 @@ 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"; @@ -82,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, From cc4567a4e7dc8371e39fa59684dd028e108cfaea Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 12:20:25 -0400 Subject: [PATCH 3/3] test: fixture-test the Rust integer-type option end to end - Support pinned-input quick-test entries that name a `.schema` file: they now run in the JSON Schema fixture with their renderer options, while the JSON fixtures ignore them. - Pin the Rust integer-type quick tests: conservative and force-i64 run against integer-type.schema; force-i32 runs against minmax-integer.schema, whose sample values all fit in i32. - Redesign integer-type.schema to cover one-sided bounds (which must stay i64), exact i32 boundaries, bounds one past them, and large bounds, using only integers that are exactly representable as IEEE doubles. The previous +/-2^63 bounds are lossy as JS doubles - the int64 max rounds up to 2^63, which overflowed the int64_t constraint constants in generated C++ - and that was why the schema had to be skipped for C++. With exact bounds the skip is no longer needed. Co-Authored-By: Claude Fable 5 --- test/fixtures.ts | 55 +++++++++++++++++++++++++- test/inputs/schema/integer-type.1.json | 11 ++++-- test/inputs/schema/integer-type.schema | 46 +++++++++++++-------- test/languages.ts | 11 +++--- 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/test/fixtures.ts b/test/fixtures.ts index dece181ef6..dfd3f1f57e 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -461,6 +461,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 +752,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 +1556,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, diff --git a/test/inputs/schema/integer-type.1.json b/test/inputs/schema/integer-type.1.json index eb9b681430..1c281a6c38 100644 --- a/test/inputs/schema/integer-type.1.json +++ b/test/inputs/schema/integer-type.1.json @@ -1,8 +1,11 @@ { "small_positive": 50, - "large_positive": 2500000000, "small_negative": -50, - "large_negative": -2500000000, - "i32_range": 1000000000, - "beyond_i32": 9000000000000000000 + "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 index b096a3443d..87ef95310d 100644 --- a/test/inputs/schema/integer-type.schema +++ b/test/inputs/schema/integer-type.schema @@ -6,38 +6,52 @@ "minimum": 0, "maximum": 100 }, - "large_positive": { - "type": "integer", - "minimum": 0, - "maximum": 5000000000 - }, "small_negative": { "type": "integer", "minimum": -100, "maximum": 0 }, - "large_negative": { - "type": "integer", - "minimum": -5000000000, - "maximum": 0 - }, "i32_range": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "beyond_i32": { + "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": -9223372036854775808, - "maximum": 9223372036854775807 + "minimum": -9007199254740991, + "maximum": 9007199254740991 } }, "required": [ "small_positive", - "large_positive", "small_negative", - "large_negative", "i32_range", - "beyond_i32" + "above_i32_max", + "below_i32_min", + "only_minimum", + "only_maximum", + "unbounded", + "large_bounds" ] } diff --git a/test/languages.ts b/test/languages.ts index 3cd8ccc7fd..98027c2397 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -327,9 +327,12 @@ export const RustLanguage: Language = { "derive-debug": "false", "derive-clone": "false", }, - { "integer-type": "conservative" }, - { "integer-type": "force-i32" }, - { "integer-type": "force-i64" }, + // 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"], }; @@ -733,8 +736,6 @@ export const CPlusPlusLanguage: Language = { skipSchema: [ // uses too much memory "keyword-unions.schema", - // seems like a problem with integer range check - "integer-type.schema", // The generated deserializer accepts non-object values when all class properties are optional. "nested-intersection-union.schema", // Recursive top-level unions produce aliases that can refer to later aliases.