effect
Version:
The missing standard library for TypeScript, for writing production-grade software.
763 lines (705 loc) • 25.4 kB
text/typescript
/**
* @since 3.10.0
*/
import * as Arr from "./Array.js"
import * as FastCheck from "./FastCheck.js"
import { globalValue } from "./GlobalValue.js"
import * as errors_ from "./internal/schema/errors.js"
import * as schemaId_ from "./internal/schema/schemaId.js"
import * as util_ from "./internal/schema/util.js"
import * as Option from "./Option.js"
import * as Predicate from "./Predicate.js"
import type * as Schema from "./Schema.js"
import * as AST from "./SchemaAST.js"
import type * as Types from "./Types.js"
/**
* @category model
* @since 3.10.0
*/
export interface LazyArbitrary<A> {
(fc: typeof FastCheck): FastCheck.Arbitrary<A>
}
/**
* @category annotations
* @since 3.10.0
*/
export interface ArbitraryGenerationContext {
readonly maxDepth: number
readonly depthIdentifier?: string
readonly constraints?: StringConstraints | NumberConstraints | BigIntConstraints | DateConstraints | ArrayConstraints
}
/**
* @category annotations
* @since 3.10.0
*/
export type ArbitraryAnnotation<A, TypeParameters extends ReadonlyArray<any> = readonly []> = (
...arbitraries: [
...{ readonly [K in keyof TypeParameters]: LazyArbitrary<TypeParameters[K]> },
ctx: ArbitraryGenerationContext
]
) => LazyArbitrary<A>
/**
* Returns a LazyArbitrary for the `A` type of the provided schema.
*
* @category arbitrary
* @since 3.10.0
*/
export const makeLazy = <A, I, R>(schema: Schema.Schema<A, I, R>): LazyArbitrary<A> =>
go(schema.ast, { maxDepth: 2 }, [])
/**
* Returns a fast-check Arbitrary for the `A` type of the provided schema.
*
* @category arbitrary
* @since 3.10.0
*/
export const make = <A, I, R>(schema: Schema.Schema<A, I, R>): FastCheck.Arbitrary<A> => makeLazy(schema)(FastCheck)
const getArbitraryAnnotation = AST.getAnnotation<ArbitraryAnnotation<any, any>>(AST.ArbitraryAnnotationId)
type Op = Succeed | Deferred
/**
* Represents an arbitrary with optional filters.
*/
class Succeed {
readonly _tag = "Succeed"
constructor(
readonly lazyArbitrary: LazyArbitrary<any>,
readonly filters: Array<Predicate.Predicate<any>> = []
) {}
toLazyArbitrary(): LazyArbitrary<any> {
return (fc) => {
let out = this.lazyArbitrary(fc)
for (const f of this.filters) {
out = out.filter(f)
}
return out
}
}
}
/**
* Represents a deferred arbitrary value generator with optional filters.
*/
class Deferred {
readonly _tag = "Deferred"
constructor(
readonly config: Config,
readonly filters: Array<Predicate.Predicate<any>> = []
) {}
toLazyArbitrary(ctx: ArbitraryGenerationContext, path: ReadonlyArray<PropertyKey>): LazyArbitrary<any> {
const config = this.config
switch (config._tag) {
case "StringConstraints": {
const pattern = config.pattern
return pattern !== undefined ?
(fc) => fc.stringMatching(new RegExp(pattern)) :
(fc) => fc.string(config.constraints)
}
case "NumberConstraints": {
return config.isInteger ?
(fc) => fc.integer(config.constraints) :
(fc) => fc.float(config.constraints)
}
case "BigIntConstraints":
return (fc) => fc.bigInt(config.constraints)
case "DateConstraints":
return (fc) => fc.date(config.constraints)
case "ArrayConstraints":
return goTupleType(config.ast, ctx, path, config.constraints)
}
}
}
interface StringConstraints {
readonly _tag: "StringConstraints"
readonly constraints: FastCheck.StringSharedConstraints
readonly pattern?: string
}
/** @internal */
export const makeStringConstraints = (options: {
readonly minLength?: number | undefined
readonly maxLength?: number | undefined
readonly pattern?: string | undefined
}): StringConstraints => {
const out: Types.Mutable<StringConstraints> = {
_tag: "StringConstraints",
constraints: {}
}
if (Predicate.isNumber(options.minLength)) {
out.constraints.minLength = options.minLength
}
if (Predicate.isNumber(options.maxLength)) {
out.constraints.maxLength = options.maxLength
}
if (Predicate.isString(options.pattern)) {
out.pattern = options.pattern
}
return out
}
interface NumberConstraints {
readonly _tag: "NumberConstraints"
readonly constraints: FastCheck.FloatConstraints
readonly isInteger: boolean
}
/** @internal */
export const makeNumberConstraints = (options: {
readonly isInteger?: boolean | undefined
readonly min?: number | undefined
readonly minExcluded?: boolean | undefined
readonly max?: number | undefined
readonly maxExcluded?: boolean | undefined
readonly noNaN?: boolean | undefined
readonly noDefaultInfinity?: boolean | undefined
}): NumberConstraints => {
const out: Types.Mutable<NumberConstraints> = {
_tag: "NumberConstraints",
constraints: {},
isInteger: options.isInteger ?? false
}
if (Predicate.isNumber(options.min)) {
out.constraints.min = Math.fround(options.min)
}
if (Predicate.isBoolean(options.minExcluded)) {
out.constraints.minExcluded = options.minExcluded
}
if (Predicate.isNumber(options.max)) {
out.constraints.max = Math.fround(options.max)
}
if (Predicate.isBoolean(options.maxExcluded)) {
out.constraints.maxExcluded = options.maxExcluded
}
if (Predicate.isBoolean(options.noNaN)) {
out.constraints.noNaN = options.noNaN
}
if (Predicate.isBoolean(options.noDefaultInfinity)) {
out.constraints.noDefaultInfinity = options.noDefaultInfinity
}
return out
}
interface BigIntConstraints {
readonly _tag: "BigIntConstraints"
readonly constraints: FastCheck.BigIntConstraints
}
/** @internal */
export const makeBigIntConstraints = (options: {
readonly min?: bigint | undefined
readonly max?: bigint | undefined
}): BigIntConstraints => {
const out: Types.Mutable<BigIntConstraints> = {
_tag: "BigIntConstraints",
constraints: {}
}
if (Predicate.isBigInt(options.min)) {
out.constraints.min = options.min
}
if (Predicate.isBigInt(options.max)) {
out.constraints.max = options.max
}
return out
}
interface ArrayConstraints {
readonly _tag: "ArrayConstraints"
readonly constraints: FastCheck.ArrayConstraints
}
/** @internal */
export const makeArrayConstraints = (options: {
readonly minLength?: number | undefined
readonly maxLength?: number | undefined
}): ArrayConstraints => {
const out: Types.Mutable<ArrayConstraints> = {
_tag: "ArrayConstraints",
constraints: {}
}
if (Predicate.isNumber(options.minLength)) {
out.constraints.minLength = options.minLength
}
if (Predicate.isNumber(options.maxLength)) {
out.constraints.maxLength = options.maxLength
}
return out
}
interface DateConstraints {
readonly _tag: "DateConstraints"
readonly constraints: FastCheck.DateConstraints
}
/** @internal */
export const makeDateConstraints = (options: {
readonly min?: Date | undefined
readonly max?: Date | undefined
readonly noInvalidDate?: boolean | undefined
}): DateConstraints => {
const out: Types.Mutable<DateConstraints> = {
_tag: "DateConstraints",
constraints: {
noInvalidDate: options.noInvalidDate ?? false
}
}
if (Predicate.isDate(options.min)) {
out.constraints.min = options.min
}
if (Predicate.isDate(options.max)) {
out.constraints.max = options.max
}
return out
}
interface ArrayConfig extends ArrayConstraints {
readonly ast: AST.TupleType
}
const makeArrayConfig = (options: {
readonly minLength?: number | undefined
readonly maxLength?: number | undefined
}, ast: AST.TupleType): ArrayConfig => {
return {
ast,
...makeArrayConstraints(options)
}
}
type Config = StringConstraints | NumberConstraints | BigIntConstraints | DateConstraints | ArrayConfig
const arbitraryMemoMap = globalValue(
Symbol.for("effect/Arbitrary/arbitraryMemoMap"),
() => new WeakMap<AST.AST, LazyArbitrary<any>>()
)
const go = (
ast: AST.AST,
ctx: ArbitraryGenerationContext,
path: ReadonlyArray<PropertyKey>
): LazyArbitrary<any> => {
const hook = getArbitraryAnnotation(ast)
if (Option.isSome(hook)) {
switch (ast._tag) {
case "Declaration":
return hook.value(...ast.typeParameters.map((p) => go(p, ctx, path)), ctx)
case "Refinement": {
const op = toOp(ast, ctx, path)
ctx = op._tag === "Deferred" ? { ...ctx, constraints: op.config } : ctx
const from = go(ast.from, ctx, path)
return new Succeed(hook.value(from, ctx), op.filters).toLazyArbitrary()
}
default:
return hook.value(ctx)
}
}
if (AST.isDeclaration(ast)) {
throw new Error(errors_.getArbitraryMissingAnnotationErrorMessage(path, ast))
}
const op = toOp(ast, ctx, path)
switch (op._tag) {
case "Succeed":
return op.toLazyArbitrary()
case "Deferred":
return new Succeed(op.toLazyArbitrary(ctx, path), op.filters).toLazyArbitrary()
}
}
const constStringConstraints = makeStringConstraints({})
const constNumberConstraints = makeNumberConstraints({})
const constBigIntConstraints = makeBigIntConstraints({})
const defaultSuspendedArrayConstraints: FastCheck.ArrayConstraints = { maxLength: 2 }
/** @internal */
export const toOp = (
ast: AST.AST,
ctx: ArbitraryGenerationContext,
path: ReadonlyArray<PropertyKey>
): Op => {
switch (ast._tag) {
case "Declaration": {
const TypeAnnotationId: any = ast.annotations[AST.SchemaIdAnnotationId]
switch (TypeAnnotationId) {
case schemaId_.DateFromSelfSchemaId:
return new Deferred(makeDateConstraints(ast.annotations[TypeAnnotationId] as any))
}
return new Succeed(go(ast, ctx, path))
}
case "Literal":
return new Succeed((fc) => fc.constant(ast.literal))
case "UniqueSymbol":
return new Succeed((fc) => fc.constant(ast.symbol))
case "UndefinedKeyword":
return new Succeed((fc) => fc.constant(undefined))
case "NeverKeyword":
throw new Error(errors_.getArbitraryMissingAnnotationErrorMessage(path, ast))
case "VoidKeyword":
case "UnknownKeyword":
case "AnyKeyword":
return new Succeed((fc) => fc.anything())
case "StringKeyword":
return new Deferred(constStringConstraints)
case "NumberKeyword":
return new Deferred(constNumberConstraints)
case "BooleanKeyword":
return new Succeed((fc) => fc.boolean())
case "BigIntKeyword":
return new Deferred(constBigIntConstraints)
case "SymbolKeyword":
return new Succeed((fc) => fc.string().map((s) => Symbol.for(s)))
case "ObjectKeyword":
return new Succeed((fc) => fc.oneof(fc.object(), fc.array(fc.anything())))
case "Enums": {
if (ast.enums.length === 0) {
throw new Error(errors_.getArbitraryEmptyEnumErrorMessage(path))
}
return new Succeed((fc) => fc.oneof(...ast.enums.map(([_, value]) => fc.constant(value))))
}
case "TemplateLiteral":
return new Succeed((fc) => {
const string = fc.string({ maxLength: 5 })
const number = fc.float({ noDefaultInfinity: true, noNaN: true })
const getTemplateLiteralArb = (ast: AST.TemplateLiteral) => {
const components: Array<FastCheck.Arbitrary<string | number>> = ast.head !== "" ? [fc.constant(ast.head)] : []
const getTemplateLiteralSpanTypeArb = (
ast: AST.TemplateLiteralSpan["type"]
): FastCheck.Arbitrary<string | number> => {
switch (ast._tag) {
case "StringKeyword":
return string
case "NumberKeyword":
return number
case "Literal":
return fc.constant(String(ast.literal))
case "Union":
return fc.oneof(...ast.types.map(getTemplateLiteralSpanTypeArb))
case "TemplateLiteral":
return getTemplateLiteralArb(ast)
}
}
ast.spans.forEach((span) => {
components.push(getTemplateLiteralSpanTypeArb(span.type))
if (span.literal !== "") {
components.push(fc.constant(span.literal))
}
})
return fc.tuple(...components).map((spans) => spans.join(""))
}
return getTemplateLiteralArb(ast)
})
case "Refinement": {
const from = toOp(ast.from, ctx, path)
const filters: Op["filters"] = [
...from.filters,
(a) => Option.isNone(ast.filter(a, AST.defaultParseOption, ast))
]
switch (from._tag) {
case "Succeed": {
return new Succeed(from.lazyArbitrary, filters)
}
case "Deferred": {
return new Deferred(merge(from.config, getConstraints(from.config._tag, ast)), filters)
}
}
}
case "TupleType":
return new Deferred(makeArrayConfig({}, ast))
case "TypeLiteral": {
const propertySignaturesTypes = ast.propertySignatures.map((ps) => go(ps.type, ctx, path.concat(ps.name)))
const indexSignatures = ast.indexSignatures.map((is) =>
[go(is.parameter, ctx, path), go(is.type, ctx, path)] as const
)
return new Succeed((fc) => {
const arbs: any = {}
const requiredKeys: Array<PropertyKey> = []
// ---------------------------------------------
// 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<any, any>(arbs, { requiredKeys })
// ---------------------------------------------
// handle index signatures
// ---------------------------------------------
for (let i = 0; i < indexSignatures.length; i++) {
const key = indexSignatures[i][0](fc)
const value = indexSignatures[i][1](fc)
output = output.chain((o) => {
const item = fc.tuple(key, value)
/*
`getSuspendedArray` is used to generate less key/value pairs in
the context of a recursive schema. Without it, the following schema
would generate an big amount of values possibly leading to a stack
overflow:
```ts
type A = { [_: string]: A }
const schema = S.Record({ key: S.String, value: S.suspend((): S.Schema<A> => schema) })
```
*/
const arr = ctx.depthIdentifier !== undefined ?
getSuspendedArray(fc, ctx.depthIdentifier, ctx.maxDepth, item, defaultSuspendedArrayConstraints) :
fc.array(item)
return arr.map((tuples) => ({ ...Object.fromEntries(tuples), ...o }))
})
}
return output
})
}
case "Union": {
const types = ast.types.map((member) => go(member, ctx, path))
return new Succeed((fc) => fc.oneof(...types.map((arb) => arb(fc))))
}
case "Suspend": {
const memo = arbitraryMemoMap.get(ast)
if (memo) {
return new Succeed(memo)
}
const get = util_.memoizeThunk(() => {
return go(ast.f(), getSuspendedContext(ctx, ast), path)
})
const out: LazyArbitrary<any> = (fc) => fc.constant(null).chain(() => get()(fc))
arbitraryMemoMap.set(ast, out)
return new Succeed(out)
}
case "Transformation":
return toOp(ast.to, ctx, path)
}
}
function subtractElementsLength(
constraints: FastCheck.ArrayConstraints,
elementsLength: number
): FastCheck.ArrayConstraints {
if (elementsLength === 0 || (constraints.minLength === undefined && constraints.maxLength === undefined)) {
return constraints
}
const out = { ...constraints }
if (out.minLength !== undefined) {
out.minLength = Math.max(out.minLength - elementsLength, 0)
}
if (out.maxLength !== undefined) {
out.maxLength = Math.max(out.maxLength - elementsLength, 0)
}
return out
}
const goTupleType = (
ast: AST.TupleType,
ctx: ArbitraryGenerationContext,
path: ReadonlyArray<PropertyKey>,
constraints: FastCheck.ArrayConstraints
): LazyArbitrary<any> => {
const elements: Array<LazyArbitrary<any>> = []
let hasOptionals = false
let i = 0
for (const element of ast.elements) {
elements.push(go(element.type, ctx, path.concat(i++)))
if (element.isOptional) {
hasOptionals = true
}
}
const rest = ast.rest.map((annotatedAST) => go(annotatedAST.type, ctx, path))
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 (Arr.isNonEmptyReadonlyArray(rest)) {
const [head, ...tail] = rest
const item = head(fc)
output = output.chain((as) => {
const len = as.length
// We must adjust the constraints for the rest element
// because the elements might have generated some values
const restArrayConstraints = subtractElementsLength(constraints, len)
if (restArrayConstraints.maxLength === 0) {
return fc.constant(as)
}
/*
`getSuspendedArray` is used to generate less values in
the context of a recursive schema. Without it, the following schema
would generate an big amount of values possibly leading to a stack
overflow:
```ts
type A = ReadonlyArray<A | null>
const schema = S.Array(
S.NullOr(S.suspend((): S.Schema<A> => schema))
)
```
*/
const arr = ctx.depthIdentifier !== undefined
? getSuspendedArray(fc, ctx.depthIdentifier, ctx.maxDepth, item, restArrayConstraints)
: fc.array(item, restArrayConstraints)
if (len === 0) {
return arr
}
return arr.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
}
}
type Constraints = StringConstraints | NumberConstraints | BigIntConstraints | DateConstraints | ArrayConstraints
const getConstraints = (_tag: Constraints["_tag"], ast: AST.Refinement): Constraints | undefined => {
const TypeAnnotationId: any = ast.annotations[AST.SchemaIdAnnotationId]
const jsonSchema: Record<string, any> = Option.getOrElse(AST.getJSONSchemaAnnotation(ast), () => ({}))
switch (_tag) {
case "StringConstraints":
return makeStringConstraints(jsonSchema)
case "NumberConstraints": {
switch (TypeAnnotationId) {
case schemaId_.NonNaNSchemaId:
return makeNumberConstraints({ noNaN: true })
default:
return makeNumberConstraints({
isInteger: "type" in jsonSchema && jsonSchema.type === "integer",
noNaN: "type" in jsonSchema && jsonSchema.type === "number" ? true : undefined,
noDefaultInfinity: "type" in jsonSchema && jsonSchema.type === "number" ? true : undefined,
min: jsonSchema.exclusiveMinimum ?? jsonSchema.minimum,
minExcluded: "exclusiveMinimum" in jsonSchema ? true : undefined,
max: jsonSchema.exclusiveMaximum ?? jsonSchema.maximum,
maxExcluded: "exclusiveMaximum" in jsonSchema ? true : undefined
})
}
}
case "BigIntConstraints":
return makeBigIntConstraints(ast.annotations[TypeAnnotationId] as any)
case "DateConstraints":
return makeDateConstraints(ast.annotations[TypeAnnotationId] as any)
case "ArrayConstraints":
return makeArrayConstraints({
minLength: jsonSchema.minItems,
maxLength: jsonSchema.maxItems
})
}
}
function getMax(n1: Date | undefined, n2: Date | undefined): Date | undefined
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 | Date | undefined,
n2: bigint | number | Date | undefined
): bigint | number | Date | undefined {
return n1 === undefined ? n2 : n2 === undefined ? n1 : n1 <= n2 ? n2 : n1
}
function getMin(n1: Date | undefined, n2: Date | undefined): Date | undefined
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 | Date | undefined,
n2: bigint | number | Date | undefined
): bigint | number | Date | undefined {
return n1 === undefined ? n2 : n2 === undefined ? n1 : n1 <= n2 ? n1 : n2
}
const getOr = (a: boolean | undefined, b: boolean | undefined): boolean | undefined => {
return a === undefined ? b : b === undefined ? a : a || b
}
function mergePattern(pattern1: string | undefined, pattern2: string | undefined): string | undefined {
if (pattern1 === undefined) {
return pattern2
}
if (pattern2 === undefined) {
return pattern1
}
return `(?:${pattern1})|(?:${pattern2})`
}
const merge = (c1: Config, c2: Constraints | undefined): Config => {
if (c2) {
switch (c1._tag) {
case "StringConstraints": {
if (c2._tag === "StringConstraints") {
return makeStringConstraints({
minLength: getMax(c1.constraints.minLength, c2.constraints.minLength),
maxLength: getMin(c1.constraints.maxLength, c2.constraints.maxLength),
pattern: mergePattern(c1.pattern, c2.pattern)
})
}
break
}
case "NumberConstraints": {
if (c2._tag === "NumberConstraints") {
return makeNumberConstraints({
isInteger: c1.isInteger || c2.isInteger,
min: getMax(c1.constraints.min, c2.constraints.min),
minExcluded: getOr(c1.constraints.minExcluded, c2.constraints.minExcluded),
max: getMin(c1.constraints.max, c2.constraints.max),
maxExcluded: getOr(c1.constraints.maxExcluded, c2.constraints.maxExcluded),
noNaN: getOr(c1.constraints.noNaN, c2.constraints.noNaN),
noDefaultInfinity: getOr(c1.constraints.noDefaultInfinity, c2.constraints.noDefaultInfinity)
})
}
break
}
case "BigIntConstraints": {
if (c2._tag === "BigIntConstraints") {
return makeBigIntConstraints({
min: getMax(c1.constraints.min, c2.constraints.min),
max: getMin(c1.constraints.max, c2.constraints.max)
})
}
break
}
case "DateConstraints": {
if (c2._tag === "DateConstraints") {
return makeDateConstraints({
min: getMax(c1.constraints.min, c2.constraints.min),
max: getMin(c1.constraints.max, c2.constraints.max),
noInvalidDate: getOr(c1.constraints.noInvalidDate, c2.constraints.noInvalidDate)
})
}
break
}
case "ArrayConstraints": {
if (c2._tag === "ArrayConstraints") {
return makeArrayConfig({
minLength: getMax(c1.constraints.minLength, c2.constraints.minLength),
maxLength: getMin(c1.constraints.maxLength, c2.constraints.maxLength)
}, c1.ast)
}
break
}
}
}
return c1
}
const getSuspendedArray = (
fc: typeof FastCheck,
depthIdentifier: string,
maxDepth: number,
item: FastCheck.Arbitrary<any>,
constraints: FastCheck.ArrayConstraints
) => {
// In the context of a recursive schema, we don't want a `maxLength` greater than 2.
// The only exception is when `minLength` is also set, in which case we set
// `maxLength` to the minimum value, which is `minLength`.
const maxLengthLimit = Math.max(2, constraints.minLength ?? 0)
if (constraints.maxLength !== undefined && constraints.maxLength > maxLengthLimit) {
constraints = { ...constraints, maxLength: maxLengthLimit }
}
return fc.oneof(
{ maxDepth, depthIdentifier },
fc.constant([]),
fc.array(item, constraints)
)
}
const getSuspendedContext = (
ctx: ArbitraryGenerationContext,
ast: AST.Suspend
): ArbitraryGenerationContext => {
if (ctx.depthIdentifier !== undefined) {
return ctx
}
const depthIdentifier = AST.getIdentifierAnnotation(ast).pipe(
Option.orElse(() => AST.getIdentifierAnnotation(ast.f())),
Option.getOrElse(() => "SuspendDefaultDepthIdentifier")
)
return { ...ctx, depthIdentifier }
}