diff --git a/packages/quicktype-core/src/language/Rust/RustRenderer.ts b/packages/quicktype-core/src/language/Rust/RustRenderer.ts index 3024d97d5b..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) => [ diff --git a/packages/quicktype-core/src/language/Rust/language.ts b/packages/quicktype-core/src/language/Rust/language.ts index 692980468a..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", @@ -30,6 +41,16 @@ export const rustOptions = { } as const, "public", ), + 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( @@ -66,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/test/fixtures.ts b/test/fixtures.ts index 45f51eb937..b7ec881474 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -486,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, @@ -772,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 { @@ -1533,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, 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/languages.ts b/test/languages.ts index d115d10249..ebc4bcc2c9 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -339,6 +339,12 @@ export const RustLanguage: Language = { "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"], };