/** * @since 1.0.0 */ import * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import * as ReadonlyArray from "effect/ReadonlyArray" import type * as FastCheck from "fast-check" import * as AST from "./AST.js" import * as Internal from "./internal/ast.js" import * as filters from "./internal/filters.js" import * as hooks from "./internal/hooks.js" import * as InternalSchema from "./internal/schema.js" import * as Parser from "./Parser.js" import type * as Schema from "./Schema.js" /** * @category model * @since 1.0.0 */ export interface Arbitrary { (fc: typeof FastCheck): FastCheck.Arbitrary } /** * @category hooks * @since 1.0.0 */ export const ArbitraryHookId: unique symbol = hooks.ArbitraryHookId /** * @category hooks * @since 1.0.0 */ export type ArbitraryHookId = typeof ArbitraryHookId /** * @category annotations * @since 1.0.0 */ export const arbitrary = (handler: (...args: ReadonlyArray>) => Arbitrary) => (self: Schema.Schema): Schema.Schema => InternalSchema.make(AST.setAnnotation(self.ast, ArbitraryHookId, handler)) /** * Returns a fast-check Arbitrary for the `A` type of the provided schema. * * @category arbitrary * @since 1.0.0 */ export const make = (schema: Schema.Schema): Arbitrary => go(schema.ast, {}) const depthSize = 1 const record = ( fc: typeof FastCheck, key: FastCheck.Arbitrary, value: FastCheck.Arbitrary, options: Options ): FastCheck.Arbitrary<{ readonly [k in K]: V }> => { return (options.isSuspend ? fc.oneof( { depthSize }, fc.constant([]), fc.array(fc.tuple(key, value), { minLength: 1, maxLength: 2 }) ) : fc.array(fc.tuple(key, value))).map((tuples) => { const out: { [k in K]: V } = {} as any for (const [k, v] of tuples) { out[k] = v } return out }) } const getHook = AST.getAnnotation< (...args: ReadonlyArray>) => Arbitrary >(ArbitraryHookId) type Options = { readonly constraints?: Constraints readonly isSuspend?: boolean } const getRefinementFromArbitrary = (ast: AST.Refinement, options: Options) => { const constraints = combineConstraints(options.constraints, getConstraints(ast)) return go(ast.from, constraints ? { ...options, constraints } : options) } const go = (ast: AST.AST, options: Options): Arbitrary => { const hook = getHook(ast) if (Option.isSome(hook)) { switch (ast._tag) { case "Declaration": return hook.value(...ast.typeParameters.map((p) => go(p, options))) case "Refinement": return hook.value(getRefinementFromArbitrary(ast, options)) default: return hook.value() } } switch (ast._tag) { case "Declaration": { throw new Error(`cannot build an Arbitrary for a declaration without annotations (${AST.format(ast)})`) } case "Literal": return (fc) => fc.constant(ast.literal) case "UniqueSymbol": return (fc) => fc.constant(ast.symbol) case "UndefinedKeyword": case "VoidKeyword": return (fc) => fc.constant(undefined) case "NeverKeyword": return () => { throw new Error("cannot build an Arbitrary for `never`") } case "UnknownKeyword": case "AnyKeyword": return (fc) => fc.anything() case "StringKeyword": return (fc) => { if (options.constraints) { switch (options.constraints._tag) { case "StringConstraints": return fc.string(options.constraints.constraints) } } return fc.string() } case "NumberKeyword": return (fc) => { if (options.constraints) { switch (options.constraints._tag) { case "NumberConstraints": return fc.float(options.constraints.constraints) case "IntegerConstraints": return fc.integer(options.constraints.constraints) } } return fc.float() } case "BooleanKeyword": return (fc) => fc.boolean() case "BigIntKeyword": return (fc) => { if (options.constraints) { switch (options.constraints._tag) { case "BigIntConstraints": return fc.bigInt(options.constraints.constraints) } } return fc.bigInt() } case "SymbolKeyword": return (fc) => fc.string().map((s) => Symbol.for(s)) case "ObjectKeyword": return (fc) => fc.oneof(fc.object(), fc.array(fc.anything())) case "TemplateLiteral": { return (fc) => { const string = fc.string({ maxLength: 5 }) const number = fc.float({ noDefaultInfinity: true }).filter((n) => !Number.isNaN(n)) const components: Array> = [fc.constant(ast.head)] for (const span of ast.spans) { if (AST.isStringKeyword(span.type)) { components.push(string) } else { components.push(number) } components.push(fc.constant(span.literal)) } return fc.tuple(...components).map((spans) => spans.join("")) } } case "Tuple": { const elements: Array> = [] let hasOptionals = false for (const element of ast.elements) { elements.push(go(element.type, options)) if (element.isOptional) { hasOptionals = true } } const rest = Option.map(ast.rest, ReadonlyArray.map((e) => go(e, options))) return (fc) => { // --------------------------------------------- // handle elements // --------------------------------------------- let output = fc.tuple(...elements.map((arb) => arb(fc))) if (hasOptionals) { const indexes = fc.tuple( ...ast.elements.map((element) => element.isOptional ? fc.boolean() : fc.constant(true)) ) output = output.chain((tuple) => indexes.map((booleans) => { for (const [i, b] of booleans.reverse().entries()) { if (!b) { tuple.splice(booleans.length - i, 1) } } return tuple }) ) } // --------------------------------------------- // handle rest element // --------------------------------------------- if (Option.isSome(rest)) { const [head, ...tail] = rest.value const arb = head(fc) const constraints = options.constraints output = output.chain((as) => { let out = fc.array(arb) if (options.isSuspend) { out = fc.oneof( { depthSize }, fc.constant([]), fc.array(arb, { minLength: 1, maxLength: 2 }) ) } else if (constraints && constraints._tag === "ArrayConstraints") { out = fc.array(arb, constraints.constraints) } return out.map((rest) => [...as, ...rest]) }) // --------------------------------------------- // handle post rest elements // --------------------------------------------- for (let j = 0; j < tail.length; j++) { output = output.chain((as) => tail[j](fc).map((a) => [...as, a])) } } return output } } case "TypeLiteral": { const propertySignaturesTypes = ast.propertySignatures.map((f) => go(f.type, options)) const indexSignatures = ast.indexSignatures.map((is) => [go(is.parameter, options), go(is.type, options)] as const ) return (fc) => { const arbs: any = {} const requiredKeys: Array = [] // --------------------------------------------- // handle property signatures // --------------------------------------------- for (let i = 0; i < propertySignaturesTypes.length; i++) { const ps = ast.propertySignatures[i] const name = ps.name if (!ps.isOptional) { requiredKeys.push(name) } arbs[name] = propertySignaturesTypes[i](fc) } let output = fc.record(arbs, { requiredKeys }) // --------------------------------------------- // handle index signatures // --------------------------------------------- for (let i = 0; i < indexSignatures.length; i++) { const parameter = indexSignatures[i][0](fc) const type = indexSignatures[i][1](fc) output = output.chain((o) => { return record(fc, parameter, type, options).map((d) => ({ ...d, ...o })) }) } return output } } case "Union": { const types = ast.types.map((t) => go(t, options)) return (fc) => fc.oneof({ depthSize }, ...types.map((arb) => arb(fc))) } case "Enums": { if (ast.enums.length === 0) { throw new Error("cannot build an Arbitrary for an empty enum") } return (fc) => fc.oneof(...ast.enums.map(([_, value]) => fc.constant(value))) } case "Refinement": { const from = getRefinementFromArbitrary(ast, options) return (fc) => from(fc).filter((a) => Option.isNone(ast.filter(a, Parser.defaultParseOption, ast))) } case "Suspend": { const get = Internal.memoizeThunk(() => go(ast.f(), { ...options, isSuspend: true })) return (fc) => fc.constant(null).chain(() => get()(fc)) } case "Transform": return go(ast.to, options) } } interface NumberConstraints { readonly _tag: "NumberConstraints" readonly constraints: FastCheck.FloatConstraints } /** @internal */ export const numberConstraints = ( constraints: NumberConstraints["constraints"] ): NumberConstraints => { if (Predicate.isNumber(constraints.min)) { constraints.min = Math.fround(constraints.min) } if (Predicate.isNumber(constraints.max)) { constraints.max = Math.fround(constraints.max) } return { _tag: "NumberConstraints", constraints } } interface StringConstraints { readonly _tag: "StringConstraints" readonly constraints: FastCheck.StringSharedConstraints } /** @internal */ export const stringConstraints = ( constraints: StringConstraints["constraints"] ): StringConstraints => { return { _tag: "StringConstraints", constraints } } interface IntegerConstraints { readonly _tag: "IntegerConstraints" readonly constraints: FastCheck.IntegerConstraints } /** @internal */ export const integerConstraints = ( constraints: IntegerConstraints["constraints"] ): IntegerConstraints => { return { _tag: "IntegerConstraints", constraints } } interface ArrayConstraints { readonly _tag: "ArrayConstraints" readonly constraints: FastCheck.ArrayConstraints } /** @internal */ export const arrayConstraints = ( constraints: ArrayConstraints["constraints"] ): ArrayConstraints => { return { _tag: "ArrayConstraints", constraints } } interface BigIntConstraints { readonly _tag: "BigIntConstraints" readonly constraints: FastCheck.BigIntConstraints } /** @internal */ export const bigintConstraints = ( constraints: BigIntConstraints["constraints"] ): BigIntConstraints => { return { _tag: "BigIntConstraints", constraints } } /** @internal */ export type Constraints = | NumberConstraints | StringConstraints | IntegerConstraints | ArrayConstraints | BigIntConstraints /** @internal */ export const getConstraints = (ast: AST.Refinement): Constraints | undefined => { const TypeAnnotationId = ast.annotations[AST.TypeAnnotationId] const jsonSchema: any = ast.annotations[AST.JSONSchemaAnnotationId] switch (TypeAnnotationId) { // number case filters.GreaterThanTypeId: case filters.GreaterThanOrEqualToTypeId: return numberConstraints({ min: jsonSchema.exclusiveMinimum ?? jsonSchema.minimum }) case filters.LessThanTypeId: case filters.LessThanOrEqualToTypeId: return numberConstraints({ max: jsonSchema.exclusiveMaximum ?? jsonSchema.maximum }) case filters.IntTypeId: return integerConstraints({}) case filters.BetweenTypeId: { const min = jsonSchema.minimum const max = jsonSchema.maximum const constraints: NumberConstraints["constraints"] = {} if (Predicate.isNumber(min)) { constraints.min = min } if (Predicate.isNumber(max)) { constraints.max = max } return numberConstraints(constraints) } // bigint case filters.GreaterThanBigintTypeId: case filters.GreaterThanOrEqualToBigintTypeId: { const params: any = ast.annotations[TypeAnnotationId] return bigintConstraints({ min: params.min }) } case filters.LessThanBigintTypeId: case filters.LessThanOrEqualToBigintTypeId: { const params: any = ast.annotations[TypeAnnotationId] return bigintConstraints({ max: params.max }) } case filters.BetweenBigintTypeId: { const params: any = ast.annotations[TypeAnnotationId] const min = params.min const max = params.max const constraints: BigIntConstraints["constraints"] = {} if (Predicate.isBigInt(min)) { constraints.min = min } if (Predicate.isBigInt(max)) { constraints.max = max } return bigintConstraints(constraints) } // string case filters.MinLengthTypeId: return stringConstraints({ minLength: jsonSchema.minLength }) case filters.MaxLengthTypeId: return stringConstraints({ maxLength: jsonSchema.maxLength }) case filters.LengthTypeId: return stringConstraints({ minLength: jsonSchema.minLength, maxLength: jsonSchema.maxLength }) // array case filters.MinItemsTypeId: return arrayConstraints({ minLength: jsonSchema.minItems }) case filters.MaxItemsTypeId: return arrayConstraints({ maxLength: jsonSchema.maxItems }) case filters.ItemsCountTypeId: return arrayConstraints({ minLength: jsonSchema.minItems, maxLength: jsonSchema.maxItems }) } } /** @internal */ export const combineConstraints = ( c1: Constraints | undefined, c2: Constraints | undefined ): Constraints | undefined => { if (c1 === undefined) { return c2 } if (c2 === undefined) { return c1 } switch (c1._tag) { case "ArrayConstraints": { switch (c2._tag) { case "ArrayConstraints": { const c: ArrayConstraints["constraints"] = { ...c1.constraints, ...c2.constraints } const minLength = getMax(c1.constraints.minLength, c2.constraints.minLength) if (Predicate.isNumber(minLength)) { c.minLength = minLength } const maxLength = getMin(c1.constraints.maxLength, c2.constraints.maxLength) if (Predicate.isNumber(maxLength)) { c.maxLength = maxLength } return arrayConstraints(c) } } break } case "NumberConstraints": { switch (c2._tag) { case "NumberConstraints": { const c: NumberConstraints["constraints"] = { ...c1.constraints, ...c2.constraints } const min = getMax(c1.constraints.min, c2.constraints.min) if (Predicate.isNumber(min)) { c.min = min } const max = getMin(c1.constraints.max, c2.constraints.max) if (Predicate.isNumber(max)) { c.max = max } return numberConstraints(c) } case "IntegerConstraints": { const c: IntegerConstraints["constraints"] = { ...c2.constraints } const min = getMax(c1.constraints.min, c2.constraints.min) if (Predicate.isNumber(min)) { c.min = min } const max = getMin(c1.constraints.max, c2.constraints.max) if (Predicate.isNumber(max)) { c.max = max } return integerConstraints(c) } } break } case "BigIntConstraints": { switch (c2._tag) { case "BigIntConstraints": { const c: BigIntConstraints["constraints"] = { ...c1.constraints, ...c2.constraints } const min = getMax(c1.constraints.min, c2.constraints.min) if (Predicate.isBigInt(min)) { c.min = min } const max = getMin(c1.constraints.max, c2.constraints.max) if (Predicate.isBigInt(max)) { c.max = max } return bigintConstraints(c) } } break } case "StringConstraints": { switch (c2._tag) { case "StringConstraints": { const c: StringConstraints["constraints"] = { ...c1.constraints, ...c2.constraints } const minLength = getMax(c1.constraints.minLength, c2.constraints.minLength) if (Predicate.isNumber(minLength)) { c.minLength = minLength } const maxLength = getMin(c1.constraints.maxLength, c2.constraints.maxLength) if (Predicate.isNumber(maxLength)) { c.maxLength = maxLength } return stringConstraints(c) } } break } case "IntegerConstraints": { switch (c2._tag) { case "NumberConstraints": case "IntegerConstraints": { const c: IntegerConstraints["constraints"] = { ...c1.constraints } const min = getMax(c1.constraints.min, c2.constraints.min) if (Predicate.isNumber(min)) { c.min = min } const max = getMin(c1.constraints.max, c2.constraints.max) if (Predicate.isNumber(max)) { c.max = max } return integerConstraints(c) } } break } } } function getMax(n1: bigint | undefined, n2: bigint | undefined): bigint | undefined function getMax(n1: number | undefined, n2: number | undefined): number | undefined function getMax( n1: bigint | number | undefined, n2: bigint | number | undefined ): bigint | number | undefined { return n1 === undefined ? n2 : n2 === undefined ? n1 : n1 <= n2 ? n2 : n1 } function getMin(n1: bigint | undefined, n2: bigint | undefined): bigint | undefined function getMin(n1: number | undefined, n2: number | undefined): number | undefined function getMin( n1: bigint | number | undefined, n2: bigint | number | undefined ): bigint | number | undefined { return n1 === undefined ? n2 : n2 === undefined ? n1 : n1 <= n2 ? n1 : n2 }