From 304c4e245e98686d7ec971e25dbc334bce1edcef Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 7 Nov 2024 20:24:14 -0500 Subject: [PATCH] fix: avoid invoking morphs on nested failures, fix index path serialization (#1193) --- ark/attest/package.json | 2 +- ark/docs/astro.config.js | 3 +- ark/fs/package.json | 2 +- ark/repo/scratch.ts | 4 - ark/schema/constraint.ts | 2 +- ark/schema/node.ts | 5 +- ark/schema/package.json | 2 +- ark/schema/shared/disjoint.ts | 6 +- ark/schema/shared/errors.ts | 62 +++++-- ark/schema/shared/implement.ts | 4 +- ark/schema/shared/traversal.ts | 44 ++--- ark/schema/shared/utils.ts | 64 ------- ark/schema/structure/index.ts | 24 ++- ark/schema/structure/optional.ts | 17 +- ark/schema/structure/prop.ts | 23 ++- ark/schema/structure/sequence.ts | 14 +- ark/schema/structure/structure.ts | 29 ++-- ark/type/__tests__/object.bench.ts | 2 +- ark/type/__tests__/operator.bench.ts | 10 +- ark/type/__tests__/pipe.test.ts | 16 ++ ark/type/__tests__/realWorld.test.ts | 18 ++ ark/type/generic.ts | 22 +-- .../keywords/constructors/constructors.ts | 4 +- ark/type/package.json | 2 +- ark/type/parser/ast/infer.ts | 6 +- ark/type/parser/objectLiteral.ts | 14 +- ark/type/parser/reduce/dynamic.ts | 12 +- ark/type/parser/reduce/static.ts | 6 +- ark/type/parser/shift/operand/enclosed.ts | 14 +- ark/type/parser/shift/operand/operand.ts | 14 +- ark/type/parser/shift/operand/unenclosed.ts | 16 +- ark/type/parser/shift/operator/bounds.ts | 8 +- ark/type/parser/shift/operator/brand.ts | 8 +- ark/type/parser/shift/operator/divisor.ts | 8 +- ark/type/parser/shift/operator/operator.ts | 20 ++- ark/type/parser/shift/scanner.ts | 159 ++++-------------- ark/type/parser/tuple.ts | 8 +- ark/type/scope.ts | 6 +- ark/util/arrays.ts | 3 +- ark/util/index.ts | 2 + ark/util/package.json | 2 +- ark/util/path.ts | 96 +++++++++++ ark/util/records.ts | 4 +- ark/util/registry.ts | 2 +- ark/util/scanner.ts | 92 ++++++++++ ark/util/strings.ts | 22 +-- 46 files changed, 526 insertions(+), 377 deletions(-) create mode 100644 ark/util/path.ts create mode 100644 ark/util/scanner.ts diff --git a/ark/attest/package.json b/ark/attest/package.json index dbdfda9ba4..4126b864fa 100644 --- a/ark/attest/package.json +++ b/ark/attest/package.json @@ -1,6 +1,6 @@ { "name": "@ark/attest", - "version": "0.25.0", + "version": "0.26.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/docs/astro.config.js b/ark/docs/astro.config.js index cfde73f246..c2fbff2a07 100644 --- a/ark/docs/astro.config.js +++ b/ark/docs/astro.config.js @@ -168,7 +168,6 @@ export default defineConfig({ vite: { resolve: { conditions: ["ark-ts"] - }, - publicDir: "public" + } } }) diff --git a/ark/fs/package.json b/ark/fs/package.json index 91ef07e1c4..56b5ce60b6 100644 --- a/ark/fs/package.json +++ b/ark/fs/package.json @@ -1,6 +1,6 @@ { "name": "@ark/fs", - "version": "0.21.0", + "version": "0.22.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 6f3f86224d..83e5165d20 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,5 +1 @@ import { type } from "arktype" - -const t = type({ - foo: "string" -}) diff --git a/ark/schema/constraint.ts b/ark/schema/constraint.ts index d9789f68b1..caf1f38595 100644 --- a/ark/schema/constraint.ts +++ b/ark/schema/constraint.ts @@ -137,7 +137,7 @@ export const constraintKeyParser = return nodes.sort((l, r) => (l.hash < r.hash ? -1 : 1)) as never } const child = ctx.$.node(kind, schema) - return child.hasOpenIntersection() ? [child] : (child as any) + return (child.hasOpenIntersection() ? [child] : child) as never } type ConstraintGroupKind = satisfy diff --git a/ark/schema/node.ts b/ark/schema/node.ts index e58343b574..41418d04fc 100644 --- a/ark/schema/node.ts +++ b/ark/schema/node.ts @@ -5,6 +5,7 @@ import { includes, isArray, isEmptyObject, + stringifyPath, throwError, type Dict, type GuardablePredicate, @@ -55,7 +56,7 @@ import { type TraverseAllows, type TraverseApply } from "./shared/traversal.ts" -import { isNode, pathToPropString, type arkKind } from "./shared/utils.ts" +import { isNode, type arkKind } from "./shared/utils.ts" import type { UndeclaredKeyHandling } from "./structure/structure.ts" export abstract class BaseNode< @@ -480,7 +481,7 @@ export type FlatRef = { } export const typePathToPropString = (path: array): string => - pathToPropString(path, { + stringifyPath(path, { stringifyNonKey: node => node.expression }) diff --git a/ark/schema/package.json b/ark/schema/package.json index c8bbb92875..2a6591afda 100644 --- a/ark/schema/package.json +++ b/ark/schema/package.json @@ -1,6 +1,6 @@ { "name": "@ark/schema", - "version": "0.21.0", + "version": "0.22.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/schema/shared/disjoint.ts b/ark/schema/shared/disjoint.ts index 3b4ec58257..c84a3d4c2b 100644 --- a/ark/schema/shared/disjoint.ts +++ b/ark/schema/shared/disjoint.ts @@ -1,4 +1,4 @@ -import { isArray, throwParseError, type Key } from "@ark/util" +import { isArray, stringifyPath, throwParseError, type Key } from "@ark/util" import type { nodeOfKind } from "../kinds.ts" import type { BaseNode } from "../node.ts" import type { Domain } from "../roots/domain.ts" @@ -6,7 +6,7 @@ import type { BaseRoot } from "../roots/root.ts" import type { Prop } from "../structure/prop.ts" import type { BoundKind } from "./implement.ts" import { $ark } from "./registry.ts" -import { isNode, pathToPropString } from "./utils.ts" +import { isNode } from "./utils.ts" export interface DisjointEntry { kind: kind @@ -66,7 +66,7 @@ export class Disjoint extends Array { describeReasons(): string { if (this.length === 1) { const { path, l, r } = this[0] - const pathString = pathToPropString(path) + const pathString = stringifyPath(path) return writeUnsatisfiableExpressionError( `Intersection${ pathString && ` at ${pathString}` diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 707e878d68..16404a1919 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -1,7 +1,11 @@ import { CastableBase, ReadonlyArray, + ReadonlyPath, + append, defineProperties, + stringifyPath, + type array, type propwiseXor, type show } from "@ark/util" @@ -10,7 +14,7 @@ import type { Prerequisite, errorContext } from "../kinds.ts" import type { NodeKind } from "./implement.ts" import type { StandardSchema } from "./standardSchema.ts" import type { TraversalContext } from "./traversal.ts" -import { arkKind, pathToPropString, type TraversalPath } from "./utils.ts" +import { arkKind } from "./utils.ts" export type ArkErrorResult = ArkError | ArkErrors @@ -18,7 +22,7 @@ export class ArkError< code extends ArkErrorCode = ArkErrorCode > extends CastableBase> { readonly [arkKind] = "error" - path: TraversalPath + path: ReadonlyPath data: Prerequisite private nodeConfig: ResolvedArkConfig[code] protected input: ArkErrorContextInput @@ -35,8 +39,11 @@ export class ArkError< ) } this.nodeConfig = ctx.config[this.code] as never - this.path = input.path ?? [...ctx.path] - if (input.relativePath) this.path.push(...input.relativePath) + this.path = + input.relativePath ? + new ReadonlyPath([...ctx.path, ...input.relativePath]) + : input.path ? new ReadonlyPath(input.path) + : new ReadonlyPath(ctx.path) this.data = "data" in input ? input.data : data } @@ -45,7 +52,7 @@ export class ArkError< } get propString(): string { - return pathToPropString(this.path) + return stringifyPath(this.path) } get expected(): string { @@ -87,6 +94,8 @@ export class ArkErrors } byPath: Record = Object.create(null) + byAncestorPath: Record = Object.create(null) + count = 0 private mutable: ArkError[] = this as never @@ -95,6 +104,19 @@ export class ArkErrors this._add(error) } + affectsPath(path: ReadonlyPath): boolean { + if (this.length === 0) return false + + return ( + // this would occur if there is an existing error at a prefix of path + // e.g. the path is ["foo", "bar"] and there is an error at ["foo"] + path.stringifyAncestors().some(s => s in this.byPath) || + // this would occur if there is an existing error at a suffix of path + // e.g. the path is ["foo"] and there is an error at ["foo", "bar"] + path.stringify() in this.byAncestorPath + ) + } + private _add(error: ArkError): void { const existing = this.byPath[error.propString] if (existing) { @@ -109,25 +131,37 @@ export class ArkErrors this.ctx ) const existingIndex = this.indexOf(existing) - // If existing is found (which it always should be unless this was externally mutated), - // replace it with the new problem intersection. In case it isn't for whatever reason, - // just append the intersection. this.mutable[existingIndex === -1 ? this.length : existingIndex] = errorIntersection + this.byPath[error.propString] = errorIntersection + // add the original error here rather than the intersection + // since the intersection is reflected by the array of errors at + // this path + this.addAncestorPaths(error) } else { this.byPath[error.propString] = error + this.addAncestorPaths(error) this.mutable.push(error) } this.count++ } + private addAncestorPaths(error: ArkError): void { + error.path.stringifyAncestors().forEach(propString => { + this.byAncestorPath[propString] = append( + this.byAncestorPath[propString], + error + ) + }) + } + merge(errors: ArkErrors): void { errors.forEach(e => { if (this.includes(e)) return this._add( new ArkError( - { ...e, path: [...e.path, ...this.ctx.path] } as never, + { ...e, path: [...this.ctx.path, ...e.path] } as never, this.ctx ) ) @@ -142,7 +176,6 @@ export class ArkErrors return this.toString() } - /** Reference to this ArkErrors array (for Standard Schema compatibility) */ get issues(): this { return this } @@ -157,7 +190,7 @@ export class ArkErrors } export type ArkErrorsMergeOptions = { - relativePath?: TraversalPath + relativePath?: array } export interface DerivableErrorContext< @@ -168,14 +201,17 @@ export interface DerivableErrorContext< problem: string message: string data: Prerequisite - path: TraversalPath + path: array propString: string } export type DerivableErrorContextInput< code extends ArkErrorCode = ArkErrorCode > = Partial> & - propwiseXor<{ path?: TraversalPath }, { relativePath?: TraversalPath }> + propwiseXor< + { path?: array }, + { relativePath?: array } + > export type ArkErrorCode = { [kind in NodeKind]: errorContext extends null ? never : kind diff --git a/ark/schema/shared/implement.ts b/ark/schema/shared/implement.ts index 6a53eb6cc0..6ad613a371 100644 --- a/ark/schema/shared/implement.ts +++ b/ark/schema/shared/implement.ts @@ -5,9 +5,9 @@ import { type Entry, type Json, type JsonData, + type KeySet, type arrayIndexOf, type entryOf, - type keySet, type keySetOf, type listable, type requireKeys, @@ -111,7 +111,7 @@ export type CompositeKind = Exclude export type OrderedNodeKinds = typeof nodeKinds -export const constraintKeys: keySet = flatMorph( +export const constraintKeys: KeySet = flatMorph( constraintKinds, (i, kind) => [kind, 1] as const ) diff --git a/ark/schema/shared/traversal.ts b/ark/schema/shared/traversal.ts index 41861c63eb..cb4a5d3ad9 100644 --- a/ark/schema/shared/traversal.ts +++ b/ark/schema/shared/traversal.ts @@ -1,4 +1,4 @@ -import type { array } from "@ark/util" +import { ReadonlyPath, type array } from "@ark/util" import type { ResolvedArkConfig } from "../config.ts" import type { Morph } from "../roots/morph.ts" import { @@ -8,10 +8,10 @@ import { type ArkErrorContextInput, type ArkErrorInput } from "./errors.ts" -import { appendPropToPathString, isNode, type TraversalPath } from "./utils.ts" +import { isNode } from "./utils.ts" export type MorphsAtPath = { - path: TraversalPath + path: ReadonlyPath morphs: array } @@ -21,7 +21,7 @@ export type BranchTraversalContext = { } export class TraversalContext { - path: TraversalPath = [] + path: PropertyKey[] = [] queuedMorphs: MorphsAtPath[] = [] errors: ArkErrors = new ArkErrors(this) branches: BranchTraversalContext[] = [] @@ -42,7 +42,7 @@ export class TraversalContext { queueMorphs(morphs: array): void { const input: MorphsAtPath = { - path: [...this.path], + path: new ReadonlyPath(this.path), morphs } if (this.currentBranch) this.currentBranch.queuedMorphs.push(input) @@ -75,14 +75,13 @@ export class TraversalContext { for (const { path, morphs } of queuedMorphs) { // even if we already have an error, apply morphs that are not at a path // with errors to capture potential validation errors - if (this.pathHasError(path)) continue - + if (this.errors.affectsPath(path)) continue this.applyMorphsAtPath(path, morphs) } } } - private applyMorphsAtPath(path: TraversalPath, morphs: array): void { + private applyMorphsAtPath(path: ReadonlyPath, morphs: array): void { const key = path.at(-1) let parent: any @@ -94,7 +93,7 @@ export class TraversalContext { parent = parent[path[pathIndex]] } - this.path = path + this.path = [...path] for (const morph of morphs) { const morphIsNode = isNode(morph) @@ -146,19 +145,6 @@ export class TraversalContext { return this.currentErrorCount !== 0 } - pathHasError(path: TraversalPath): boolean { - if (!this.hasError()) return false - - let partialPropString: string = "" - // this.errors.byPath is null prototyped so indexing by string is safe - if (this.errors.byPath[partialPropString]) return true - for (let i = 0; i < path.length; i++) { - partialPropString = appendPropToPathString(partialPropString, path[i]) - if (this.errors.byPath[partialPropString]) return true - } - return false - } - get failFast(): boolean { return this.branches.length !== 0 } @@ -210,6 +196,20 @@ export class TraversalContext { } } +export const traverseKey = ( + key: PropertyKey, + fn: () => result, + // ctx will be undefined if this node isn't context-dependent + ctx: TraversalContext | undefined +): result => { + if (!ctx) return fn() + + ctx.path.push(key) + const result = fn() + ctx.path.pop() + return result +} + export type TraversalMethodsByKind = { Allows: TraverseAllows Apply: TraverseApply diff --git a/ark/schema/shared/utils.ts b/ark/schema/shared/utils.ts index 10d3d90f21..567a3744e7 100644 --- a/ark/schema/shared/utils.ts +++ b/ark/schema/shared/utils.ts @@ -1,13 +1,9 @@ import { flatMorph, isArray, - isDotAccessible, noSuggest, - printable, - throwParseError, type array, type mutable, - type requireKeys, type show } from "@ark/util" import type { BaseConstraint } from "../constraint.ts" @@ -44,66 +40,6 @@ export type internalImplementationOf< : unknown } -export type TraversalPath = PropertyKey[] - -export type PathToPropStringOptions = requireKeys< - { - stringifySymbol?: (s: symbol) => string - stringifyNonKey?: (o: Exclude) => string - }, - stringifiable extends PropertyKey ? never : "stringifyNonKey" -> - -export type PathToPropStringFn = ( - path: array, - ...[opts]: [stringifiable] extends [PropertyKey] ? - [opts?: PathToPropStringOptions] - : NoInfer<[opts: PathToPropStringOptions]> -) => string - -export type AppendPropToPathStringFn = ( - path: string, - prop: stringifiable, - ...[opts]: [stringifiable] extends [PropertyKey] ? - [opts?: PathToPropStringOptions] - : NoInfer<[opts: PathToPropStringOptions]> -) => string - -export const appendPropToPathString: AppendPropToPathStringFn = ( - path, - prop, - ...[opts] -) => { - const stringifySymbol = opts?.stringifySymbol ?? printable - let propAccessChain: string = path - switch (typeof prop) { - case "string": - propAccessChain = - isDotAccessible(prop) ? - path === "" ? - prop - : `${path}.${prop}` - : `${path}[${JSON.stringify(prop)}]` - break - case "number": - propAccessChain = `${path}[${prop}]` - break - case "symbol": - propAccessChain = `${path}[${stringifySymbol(prop)}]` - break - default: - if (opts?.stringifyNonKey) - propAccessChain = `${path}[${opts.stringifyNonKey(prop as never)}]` - throwParseError( - `${printable(prop)} must be a PropertyKey or stringifyNonKey must be passed to options` - ) - } - return propAccessChain -} - -export const pathToPropString: PathToPropStringFn = (path, ...opts) => - path.reduce((s, k) => appendPropToPathString(s, k, ...opts), "") - export type arkKind = typeof arkKind export const arkKind = noSuggest("arkKind") diff --git a/ark/schema/structure/index.ts b/ark/schema/structure/index.ts index 15602fc22e..a759a8b1b8 100644 --- a/ark/schema/structure/index.ts +++ b/ark/schema/structure/index.ts @@ -23,7 +23,11 @@ import { } from "../shared/implement.ts" import { intersectOrPipeNodes } from "../shared/intersections.ts" import { $ark } from "../shared/registry.ts" -import type { TraverseAllows, TraverseApply } from "../shared/traversal.ts" +import { + traverseKey, + type TraverseAllows, + type TraverseApply +} from "../shared/traversal.ts" export declare namespace Index { export type KeyKind = Exclude @@ -121,11 +125,11 @@ export class IndexNode extends BaseConstraint { traverseAllows: TraverseAllows = (data, ctx) => stringAndSymbolicEntriesOf(data).every(entry => { if (this.signature.traverseAllows(entry[0], ctx)) { - // ctx will be undefined if this node isn't context-dependent - ctx?.path.push(entry[0]) - const allowed = this.value.traverseAllows(entry[1], ctx) - ctx?.path.pop() - return allowed + return traverseKey( + entry[0], + () => this.value.traverseAllows(entry[1], ctx), + ctx + ) } return true }) @@ -133,9 +137,11 @@ export class IndexNode extends BaseConstraint { traverseApply: TraverseApply = (data, ctx) => stringAndSymbolicEntriesOf(data).forEach(entry => { if (this.signature.traverseAllows(entry[0], ctx)) { - ctx.path.push(entry[0]) - this.value.traverseApply(entry[1], ctx) - ctx.path.pop() + traverseKey( + entry[0], + () => this.value.traverseApply(entry[1], ctx), + ctx + ) } }) diff --git a/ark/schema/structure/optional.ts b/ark/schema/structure/optional.ts index d299cc7d19..0de38ed66c 100644 --- a/ark/schema/structure/optional.ts +++ b/ark/schema/structure/optional.ts @@ -15,6 +15,7 @@ import { type nodeImplementationOf } from "../shared/implement.ts" import { registeredReference } from "../shared/registry.ts" +import { traverseKey } from "../shared/traversal.ts" import { BaseProp, intersectProps, type Prop } from "./prop.ts" export declare namespace Optional { @@ -109,9 +110,11 @@ export class OptionalNode extends BaseProp<"optional"> { // if the value has a morph, pipe context through it this.value.includesMorph ? (data, ctx) => { - ctx.path.push(this.key) - this.value((data[this.key] = defaultInput()), ctx) - ctx.path.pop() + traverseKey( + this.key, + () => this.value((data[this.key] = defaultInput()), ctx), + ctx + ) return data } : data => { @@ -130,9 +133,11 @@ export class OptionalNode extends BaseProp<"optional"> { hasDomain(precomputedMorphedDefault, "object") ? // the type signature only allows this if the value was morphed (data, ctx) => { - ctx.path.push(this.key) - this.value((data[this.key] = defaultInput), ctx) - ctx.path.pop() + traverseKey( + this.key, + () => this.value((data[this.key] = defaultInput), ctx), + ctx + ) return data } : data => { diff --git a/ark/schema/structure/prop.ts b/ark/schema/structure/prop.ts index e8b02ce71d..ffef0d9de3 100644 --- a/ark/schema/structure/prop.ts +++ b/ark/schema/structure/prop.ts @@ -15,7 +15,11 @@ import { Disjoint } from "../shared/disjoint.ts" import type { IntersectionContext, RootKind } from "../shared/implement.ts" import { intersectOrPipeNodes } from "../shared/intersections.ts" import { $ark } from "../shared/registry.ts" -import type { TraverseAllows, TraverseApply } from "../shared/traversal.ts" +import { + traverseKey, + type TraverseAllows, + type TraverseApply +} from "../shared/traversal.ts" import type { Optional } from "./optional.ts" import type { Required } from "./required.ts" @@ -128,19 +132,22 @@ export abstract class BaseProp< traverseAllows: TraverseAllows = (data, ctx) => { if (this.key in data) { // ctx will be undefined if this node isn't context-dependent - ctx?.path.push(this.key) - const allowed = this.value.traverseAllows((data as any)[this.key], ctx) - ctx?.path.pop() - return allowed + return traverseKey( + this.key, + () => this.value.traverseAllows((data as any)[this.key], ctx), + ctx + ) } return this.optional } traverseApply: TraverseApply = (data, ctx) => { if (this.key in data) { - ctx.path.push(this.key) - this.value.traverseApply((data as any)[this.key], ctx) - ctx.path.pop() + traverseKey( + this.key, + () => this.value.traverseApply((data as any)[this.key], ctx), + ctx + ) } else if (this.hasKind("required")) ctx.error(this.errorContext) else if (this.hasDefault()) ctx.queueMorphs(this.defaultValueMorphs) } diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index de59fad85d..fb3505693b 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -37,7 +37,11 @@ import { type JsonSchema } from "../shared/jsonSchema.ts" import { $ark } from "../shared/registry.ts" -import type { TraverseAllows, TraverseApply } from "../shared/traversal.ts" +import { + traverseKey, + type TraverseAllows, + type TraverseApply +} from "../shared/traversal.ts" export declare namespace Sequence { export interface NormalizedSchema extends BaseNormalizedSchema { @@ -301,9 +305,11 @@ export class SequenceNode extends BaseConstraint { traverseApply: TraverseApply = (data, ctx) => { for (let i = 0; i < data.length; i++) { - ctx.path.push(i) - this.childAtIndex(data, i).traverseApply(data[i], ctx) - ctx.path.pop() + traverseKey( + i, + () => this.childAtIndex(data, i).traverseApply(data[i], ctx), + ctx + ) } } diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index 35bb2bfaef..aab27458b6 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -37,11 +37,12 @@ import { registeredReference, type RegisteredReference } from "../shared/registry.ts" -import type { - TraversalContext, - TraversalKind, - TraverseAllows, - TraverseApply +import { + traverseKey, + type TraversalContext, + type TraversalKind, + type TraverseAllows, + type TraverseApply } from "../shared/traversal.ts" import { hasArkKind, @@ -555,14 +556,18 @@ export class StructureNode extends BaseConstraint { for (const node of this.index) { if (node.signature.traverseAllows(k, ctx)) { if (traversalKind === "Allows") { - ctx?.path.push(k) - const result = node.value.traverseAllows(data[k as never], ctx) - ctx?.path.pop() + const result = traverseKey( + k, + () => node.value.traverseAllows(data[k as never], ctx), + ctx + ) if (!result) return false } else { - ctx.path.push(k) - node.value.traverseApply(data[k as never], ctx) - ctx.path.pop() + traverseKey( + k, + () => node.value.traverseApply(data[k as never], ctx), + ctx + ) if (ctx.failFast && ctx.currentErrorCount > errorCount) return false } @@ -594,8 +599,6 @@ export class StructureNode extends BaseConstraint { if (ctx.failFast) return false } } - - ctx?.path.pop() } return true diff --git a/ark/type/__tests__/object.bench.ts b/ark/type/__tests__/object.bench.ts index 8894171d18..003b69f26d 100644 --- a/ark/type/__tests__/object.bench.ts +++ b/ark/type/__tests__/object.bench.ts @@ -71,4 +71,4 @@ bench("nested type invocations", () => }) .array() }) -).types([21549, "instantiations"]) +).types([21572, "instantiations"]) diff --git a/ark/type/__tests__/operator.bench.ts b/ark/type/__tests__/operator.bench.ts index c8bb807ae5..cf08130450 100644 --- a/ark/type/__tests__/operator.bench.ts +++ b/ark/type/__tests__/operator.bench.ts @@ -77,22 +77,22 @@ bench("group-deep", () => type("(0|(1|(2|(3|(4|5)[])[])[])[])[]")).types([ "instantiations" ]) -bench("bound-single", () => type("string>5")).types([1673, "instantiations"]) +bench("bound-single", () => type("string>5")).types([1679, "instantiations"]) bench("bound-double", () => type("-7<=string.integer<99")).types([ - 2627, + 2642, "instantiations" ]) -bench("divisor", () => type("number%5")).types([1245, "instantiations"]) +bench("divisor", () => type("number%5")).types([1254, "instantiations"]) bench("filter-tuple", () => type(["boolean", ":", b => b])).types([ - 1444, + 1432, "instantiations" ]) bench("filter-chain", () => type("boolean").narrow(b => b)).types([ - 1003, + 1015, "instantiations" ]) diff --git a/ark/type/__tests__/pipe.test.ts b/ark/type/__tests__/pipe.test.ts index d56cc16f30..99d6553112 100644 --- a/ark/type/__tests__/pipe.test.ts +++ b/ark/type/__tests__/pipe.test.ts @@ -921,4 +921,20 @@ Right: { foo: (In: string) => Out<{ [string]: $jsonObject | number | string | $j const U = types.Morph.pipe(e => e, types.To) attest(U({ a: 1 })).snap({ a: 2 }) }) + + // https://github.com/arktypeio/arktype/issues/1185 + it("pipe doesn't run on rejected descendant prop", () => { + let callCount = 0 + const t = type({ + key: "string" + }).pipe(v => { + callCount++ + return v + }) + + const out = t({}) + + attest(out.toString()).snap("key must be a string (was missing)") + attest(callCount).equals(0) + }) }) diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index cb4c8a64a3..1124524e8e 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -1024,4 +1024,22 @@ nospace must be matched by ^\\S*$ (was "One space")`) attest(base.json).equals(identity.json) attest(base.internal.id).equals(identity.internal.id) }) + + it("index signature union intersection with default", () => { + const t = type({ + storeA: "Record" + }) + .or({ + storeB: { + foo: "Record" + } + }) + .and({ + ext: ["string", "=", ".txt"] + }) + + attest(t.expression).snap( + '{ storeA: { [string]: string }, ext?: string = ".txt" } | { storeB: { foo: { [string]: string } }, ext?: string = ".txt" }' + ) + }) }) diff --git a/ark/type/generic.ts b/ark/type/generic.ts index 9c2eb4d57c..b16cf7865a 100644 --- a/ark/type/generic.ts +++ b/ark/type/generic.ts @@ -17,7 +17,7 @@ import { type ErrorType, type Hkt, type Json, - type WhiteSpaceToken + type WhitespaceChar } from "@ark/util" import type { type } from "./keywords/keywords.ts" import type { inferAstRoot } from "./parser/ast/infer.ts" @@ -28,7 +28,7 @@ import type { } from "./parser/definition.ts" import { DynamicState } from "./parser/reduce/dynamic.ts" import type { state, StaticState } from "./parser/reduce/static.ts" -import type { Scanner } from "./parser/shift/scanner.ts" +import type { ArkTypeScanner } from "./parser/shift/scanner.ts" import { parseUntilFinalizer } from "./parser/string.ts" import type { Scope } from "./scope.ts" import type { Type } from "./type.ts" @@ -280,16 +280,16 @@ export const emptyGenericParameterMessage = export type emptyGenericParameterMessage = typeof emptyGenericParameterMessage export type parseGenericParams = parseNextNameChar< - Scanner.skipWhitespace, + ArkTypeScanner.skipWhitespace, "", [], $ > -type ParamsTerminator = WhiteSpaceToken | "," +type ParamsTerminator = WhitespaceChar | "," export const parseGenericParamName = ( - scanner: Scanner, + scanner: ArkTypeScanner, result: GenericParamDef[], ctx: BaseParseContext ): GenericParamDef[] => { @@ -311,7 +311,7 @@ type parseName< unscanned extends string, result extends array, $ -> = parseNextNameChar, "", result, $> +> = parseNextNameChar, "", result, $> type parseNextNameChar< unscanned extends string, @@ -324,7 +324,7 @@ type parseNextNameChar< name extends "" ? ErrorMessage : lookahead extends "," ? parseName - : lookahead extends WhiteSpaceToken ? + : lookahead extends WhitespaceChar ? _parseOptionalConstraint : never : parseNextNameChar @@ -336,7 +336,7 @@ const extendsToken = "extends " type extendsToken = typeof extendsToken const _parseOptionalConstraint = ( - scanner: Scanner, + scanner: ArkTypeScanner, name: string, result: GenericParamDef[], ctx: BaseParseContext @@ -364,7 +364,7 @@ type _parseOptionalConstraint< result extends array, $ > = - Scanner.skipWhitespace extends ( + ArkTypeScanner.skipWhitespace extends ( `${extendsToken}${infer nextUnscanned}` ) ? parseUntilFinalizer, $, {}> extends ( @@ -381,7 +381,9 @@ type _parseOptionalConstraint< > : never : parseName< - Scanner.skipWhitespace extends `,${infer nextUnscanned}` ? + ArkTypeScanner.skipWhitespace extends ( + `,${infer nextUnscanned}` + ) ? nextUnscanned : unscanned, [...result, [name, unknown]], diff --git a/ark/type/keywords/constructors/constructors.ts b/ark/type/keywords/constructors/constructors.ts index cd6293c4a8..f22b35f53a 100644 --- a/ark/type/keywords/constructors/constructors.ts +++ b/ark/type/keywords/constructors/constructors.ts @@ -3,7 +3,7 @@ import { flatMorph, platformConstructors, type EcmascriptObjects, - type keySet, + type KeySet, type PlatformObjects } from "@ark/util" import type { Module, Submodule } from "../../module.ts" @@ -16,7 +16,7 @@ const omittedPrototypes = { Boolean: 1, Number: 1, String: 1 -} satisfies keySet +} satisfies KeySet export const arkPrototypes: arkPrototypes.module = arkModule({ ...flatMorph( diff --git a/ark/type/package.json b/ark/type/package.json index 47f0c8583f..9903ca0661 100644 --- a/ark/type/package.json +++ b/ark/type/package.json @@ -1,7 +1,7 @@ { "name": "arktype", "description": "TypeScript's 1:1 validator, optimized from editor to runtime", - "version": "2.0.0-rc.21", + "version": "2.0.0-rc.22", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/type/parser/ast/infer.ts b/ark/type/parser/ast/infer.ts index fcbf942bff..f68ae5c429 100644 --- a/ark/type/parser/ast/infer.ts +++ b/ark/type/parser/ast/infer.ts @@ -30,7 +30,7 @@ import type { type } from "../../keywords/keywords.ts" import type { UnparsedScope } from "../../scope.ts" import type { inferDefinition } from "../definition.ts" import type { Comparator } from "../reduce/shared.ts" -import type { Scanner } from "../shift/scanner.ts" +import type { ArkTypeScanner } from "../shift/scanner.ts" export type inferAstRoot = ast extends array ? inferExpression : never @@ -182,12 +182,12 @@ export type PrefixExpression< > = [operator, operand] export type PostfixExpression< - operator extends Scanner.PostfixToken = Scanner.PostfixToken, + operator extends ArkTypeScanner.PostfixToken = ArkTypeScanner.PostfixToken, operand = unknown > = readonly [operand, operator] export type InfixExpression< - operator extends Scanner.InfixToken = Scanner.InfixToken, + operator extends ArkTypeScanner.InfixToken = ArkTypeScanner.InfixToken, l = unknown, r = unknown > = [l, operator, r] diff --git a/ark/type/parser/objectLiteral.ts b/ark/type/parser/objectLiteral.ts index 2fdcfc288d..7be4ef8295 100644 --- a/ark/type/parser/objectLiteral.ts +++ b/ark/type/parser/objectLiteral.ts @@ -12,7 +12,7 @@ import { } from "@ark/schema" import { append, - escapeToken, + escapeChar, printable, stringAndSymbolicEntriesOf, throwParseError, @@ -20,7 +20,7 @@ import { type Dict, type ErrorMessage, type ErrorType, - type EscapeToken, + type EscapeChar, type Key, type listable, type merge, @@ -218,7 +218,7 @@ export const parseEntry = ( const parseKey = (key: Key): PreparsedKey => typeof key === "symbol" ? { kind: "required", key } : key.at(-1) === "?" ? - key.at(-2) === escapeToken ? + key.at(-2) === escapeChar ? { kind: "required", key: `${key.slice(0, -2)}?` } : { kind: "optional", @@ -226,7 +226,7 @@ const parseKey = (key: Key): PreparsedKey => } : key[0] === "[" && key.at(-1) === "]" ? { kind: "index", key: key.slice(1, -1) } - : key[0] === escapeToken && key[1] === "[" && key.at(-1) === "]" ? + : key[0] === escapeChar && key[1] === "[" && key.at(-1) === "]" ? { kind: "required", key: key.slice(1) } : key === "..." ? { kind: key, key } : key === "+" ? { kind: key, key } @@ -240,7 +240,7 @@ const parseKey = (key: Key): PreparsedKey => type parseKey = k extends `${infer inner}?` ? - inner extends `${infer baseName}${EscapeToken}` ? + inner extends `${infer baseName}${EscapeChar}` ? PreparsedKey.from<{ kind: "required" key: `${baseName}?` @@ -250,7 +250,7 @@ type parseKey = key: inner }> : k extends MetaKey ? PreparsedKey.from<{ kind: k; key: k }> - : k extends `${EscapeToken}${infer escapedMeta extends MetaKey}` ? + : k extends `${EscapeChar}${infer escapedMeta extends MetaKey}` ? PreparsedKey.from<{ kind: "required"; key: escapedMeta }> : k extends IndexKey ? PreparsedKey.from<{ @@ -259,7 +259,7 @@ type parseKey = }> : PreparsedKey.from<{ kind: "required" - key: k extends `${EscapeToken}${infer escapedIndexKey extends IndexKey}` ? + key: k extends `${EscapeChar}${infer escapedIndexKey extends IndexKey}` ? escapedIndexKey : k extends Key ? k : `${k & number}` diff --git a/ark/type/parser/reduce/dynamic.ts b/ark/type/parser/reduce/dynamic.ts index 2c5f806d03..2bffb03f06 100644 --- a/ark/type/parser/reduce/dynamic.ts +++ b/ark/type/parser/reduce/dynamic.ts @@ -8,7 +8,7 @@ import { import type { LimitLiteral } from "../../attributes.ts" import { parseOperand } from "../shift/operand/operand.ts" import { parseOperator } from "../shift/operator/operator.ts" -import type { Scanner } from "../shift/scanner.ts" +import type { ArkTypeScanner } from "../shift/scanner.ts" import { parseUntilFinalizer } from "../string.ts" import { type Comparator, @@ -42,13 +42,13 @@ export class DynamicState { intersection: null, union: null } - finalizer: Scanner.FinalizingLookahead | undefined + finalizer: ArkTypeScanner.FinalizingLookahead | undefined groups: BranchState[] = [] - scanner: Scanner + scanner: ArkTypeScanner ctx: BaseParseContext - constructor(scanner: Scanner, ctx: BaseParseContext) { + constructor(scanner: ArkTypeScanner, ctx: BaseParseContext) { this.scanner = scanner this.ctx = ctx } @@ -75,7 +75,7 @@ export class DynamicState { this.root = this.root!.constrain(args[0], args[1]) } - finalize(finalizer: Scanner.FinalizingLookahead): void { + finalize(finalizer: ArkTypeScanner.FinalizingLookahead): void { if (this.groups.length) return this.error(writeUnclosedGroupMessage(")")) this.finalizeBranches() @@ -188,7 +188,7 @@ export class DynamicState { previousOperator(): | MinComparator | StringifiablePrefixOperator - | Scanner.InfixToken + | ArkTypeScanner.InfixToken | undefined { return ( this.branches.leftBound?.comparator ?? diff --git a/ark/type/parser/reduce/static.ts b/ark/type/parser/reduce/static.ts index e71fa8bf48..a2514ad1de 100644 --- a/ark/type/parser/reduce/static.ts +++ b/ark/type/parser/reduce/static.ts @@ -1,6 +1,6 @@ import type { Completion, ErrorMessage, defined } from "@ark/util" import type { LimitLiteral } from "../../attributes.ts" -import type { Scanner } from "../shift/scanner.ts" +import type { ArkTypeScanner } from "../shift/scanner.ts" import type { Comparator, InvertedComparators, @@ -19,7 +19,7 @@ export type StaticState = { root: unknown branches: BranchState groups: BranchState[] - finalizer: Scanner.FinalizingLookahead | ErrorMessage | undefined + finalizer: ArkTypeScanner.FinalizingLookahead | ErrorMessage | undefined scanned: string unscanned: string } @@ -252,7 +252,7 @@ export declare namespace state { export type finalize< s extends StaticState, - finalizer extends Scanner.FinalizingLookahead + finalizer extends ArkTypeScanner.FinalizingLookahead > = s["groups"] extends [] ? s["branches"]["leftBound"] extends {} ? diff --git a/ark/type/parser/shift/operand/enclosed.ts b/ark/type/parser/shift/operand/enclosed.ts index 7622b43234..8956dba67c 100644 --- a/ark/type/parser/shift/operand/enclosed.ts +++ b/ark/type/parser/shift/operand/enclosed.ts @@ -1,10 +1,10 @@ -import { isKeyOf, throwParseError } from "@ark/util" +import { isKeyOf, throwParseError, type Scanner } from "@ark/util" import type { string } from "../../../attributes.ts" import type { Date } from "../../../keywords/constructors/Date.ts" import type { InferredAst } from "../../ast/infer.ts" import type { DynamicState } from "../../reduce/dynamic.ts" import type { StaticState, state } from "../../reduce/static.ts" -import type { Scanner } from "../scanner.ts" +import type { ArkTypeScanner } from "../scanner.ts" import { tryParseDate, writeInvalidDateMessage } from "./date.ts" export type StringLiteral = @@ -57,9 +57,10 @@ export type parseEnclosed< enclosingStart extends EnclosingStartToken, unscanned extends string > = - Scanner.shiftUntil extends ( - Scanner.shiftResult - ) ? + ArkTypeScanner.shiftUntil< + unscanned, + EnclosingTokens[enclosingStart] + > extends ArkTypeScanner.shiftResult ? nextUnscanned extends "" ? state.error> : state.setRoot< @@ -70,7 +71,8 @@ export type parseEnclosed< : Date.nominal, `${enclosingStart}${scanned}${EnclosingTokens[enclosingStart]}` >, - nextUnscanned extends Scanner.shift ? unscanned + nextUnscanned extends ArkTypeScanner.shift ? + unscanned : "" > : never diff --git a/ark/type/parser/shift/operand/operand.ts b/ark/type/parser/shift/operand/operand.ts index 5fd26b2b33..4e6b1f8c41 100644 --- a/ark/type/parser/shift/operand/operand.ts +++ b/ark/type/parser/shift/operand/operand.ts @@ -1,8 +1,8 @@ -import { whiteSpaceTokens, type WhiteSpaceToken } from "@ark/util" +import { whitespaceChars, type WhitespaceChar } from "@ark/util" import type { DynamicState } from "../../reduce/dynamic.ts" import type { StaticState, state } from "../../reduce/static.ts" import type { BaseCompletions } from "../../string.ts" -import type { Scanner } from "../scanner.ts" +import type { ArkTypeScanner } from "../scanner.ts" import { enclosingChar, enclosingQuote, @@ -16,7 +16,7 @@ export const parseOperand = (s: DynamicState): void => s.scanner.lookahead === "" ? s.error(writeMissingOperandMessage(s)) : s.scanner.lookahead === "(" ? s.shiftedByOne().reduceGroupOpen() : s.scanner.lookaheadIsIn(enclosingChar) ? parseEnclosed(s, s.scanner.shift()) - : s.scanner.lookaheadIsIn(whiteSpaceTokens) ? parseOperand(s.shiftedByOne()) + : s.scanner.lookaheadIsIn(whitespaceChars) ? parseOperand(s.shiftedByOne()) : s.scanner.lookahead === "d" ? s.scanner.nextLookahead in enclosingQuote ? parseEnclosed( @@ -27,15 +27,17 @@ export const parseOperand = (s: DynamicState): void => : parseUnenclosed(s) export type parseOperand = - s["unscanned"] extends Scanner.shift ? + s["unscanned"] extends ( + ArkTypeScanner.shift + ) ? lookahead extends "(" ? state.reduceGroupOpen : lookahead extends EnclosingStartToken ? parseEnclosed - : lookahead extends WhiteSpaceToken ? + : lookahead extends WhitespaceChar ? parseOperand, $, args> : lookahead extends "d" ? unscanned extends ( - Scanner.shift< + ArkTypeScanner.shift< infer enclosing extends EnclosingQuote, infer nextUnscanned > diff --git a/ark/type/parser/shift/operand/unenclosed.ts b/ark/type/parser/shift/operand/unenclosed.ts index 29c9e418ed..a4d0849a0d 100644 --- a/ark/type/parser/shift/operand/unenclosed.ts +++ b/ark/type/parser/shift/operand/unenclosed.ts @@ -26,7 +26,7 @@ import { writePrefixedPrivateReferenceMessage } from "../../ast/validate.ts" import type { DynamicState } from "../../reduce/dynamic.ts" import type { StaticState, state } from "../../reduce/static.ts" import type { BaseCompletions } from "../../string.ts" -import type { Scanner } from "../scanner.ts" +import type { ArkTypeScanner } from "../scanner.ts" import { parseGenericArgs, writeInvalidGenericArgCountMessage, @@ -40,8 +40,8 @@ export const parseUnenclosed = (s: DynamicState): void => { } export type parseUnenclosed = - Scanner.shiftUntilNextTerminator extends ( - Scanner.shiftResult + ArkTypeScanner.shiftUntilNextTerminator extends ( + ArkTypeScanner.shiftResult ) ? tryResolve extends state.from ? s @@ -84,7 +84,7 @@ export type parseGenericInstantiation< args > = // skip whitepsace to allow instantiations like `Partial ` - Scanner.skipWhitespace extends `<${infer unscanned}` ? + ArkTypeScanner.skipWhitespace extends `<${infer unscanned}` ? parseGenericArgs extends infer result ? result extends ParsedArgs ? state.setRoot, nextUnscanned> @@ -231,9 +231,11 @@ export type unresolvableState< args, submodulePath extends string[] > = - [token, s["unscanned"]] extends ["", Scanner.shift<"#", infer unscanned>] ? - Scanner.shiftUntilNextTerminator extends ( - Scanner.shiftResult + [token, s["unscanned"]] extends ( + ["", ArkTypeScanner.shift<"#", infer unscanned>] + ) ? + ArkTypeScanner.shiftUntilNextTerminator extends ( + ArkTypeScanner.shiftResult ) ? state.error> : never diff --git a/ark/type/parser/shift/operator/bounds.ts b/ark/type/parser/shift/operator/bounds.ts index a94a3fd686..6d8b36ae11 100644 --- a/ark/type/parser/shift/operator/bounds.ts +++ b/ark/type/parser/shift/operator/bounds.ts @@ -5,7 +5,7 @@ import { type BoundKind, type NodeSchema } from "@ark/schema" -import { isKeyOf, throwParseError, type keySet } from "@ark/util" +import { isKeyOf, throwParseError, type KeySet } from "@ark/util" import type { DateLiteral } from "../../../attributes.ts" import type { InferredAst } from "../../ast/infer.ts" import type { astToString } from "../../ast/utils.ts" @@ -25,7 +25,7 @@ import { import type { state, StaticState } from "../../reduce/static.ts" import { extractDateLiteralSource, isDateLiteral } from "../operand/date.ts" import type { parseOperand } from "../operand/operand.ts" -import type { Scanner } from "../scanner.ts" +import type { ArkTypeScanner } from "../scanner.ts" export const parseBound = ( s: DynamicStateWithRoot, @@ -58,7 +58,7 @@ export type parseBound< > = shiftComparator extends infer shiftResultOrError ? shiftResultOrError extends ( - Scanner.shiftResult< + ArkTypeScanner.shiftResult< infer comparator extends Comparator, infer nextUnscanned > @@ -79,7 +79,7 @@ type OneCharComparator = ">" | "<" export type ComparatorStartChar = Comparator extends `${infer char}${string}` ? char : never -export const comparatorStartChars: keySet = { +export const comparatorStartChars: KeySet = { "<": 1, ">": 1, "=": 1 diff --git a/ark/type/parser/shift/operator/brand.ts b/ark/type/parser/shift/operator/brand.ts index 5f894b9323..95642af750 100644 --- a/ark/type/parser/shift/operator/brand.ts +++ b/ark/type/parser/shift/operator/brand.ts @@ -1,7 +1,7 @@ import type { emptyBrandNameMessage } from "@ark/schema" import type { DynamicStateWithRoot } from "../../reduce/dynamic.ts" import type { StaticState, state } from "../../reduce/static.ts" -import type { Scanner } from "../scanner.ts" +import type { ArkTypeScanner } from "../scanner.ts" export const parseBrand = (s: DynamicStateWithRoot): void => { s.scanner.shiftUntilNonWhitespace() @@ -10,8 +10,10 @@ export const parseBrand = (s: DynamicStateWithRoot): void => { } export type parseBrand = - Scanner.shiftUntilNextTerminator> extends ( - Scanner.shiftResult<`${infer brandName}`, infer nextUnscanned> + ArkTypeScanner.shiftUntilNextTerminator< + ArkTypeScanner.skipWhitespace + > extends ( + ArkTypeScanner.shiftResult<`${infer brandName}`, infer nextUnscanned> ) ? brandName extends "" ? state.error diff --git a/ark/type/parser/shift/operator/divisor.ts b/ark/type/parser/shift/operator/divisor.ts index 085f592cd6..2ea5e93fe5 100644 --- a/ark/type/parser/shift/operator/divisor.ts +++ b/ark/type/parser/shift/operator/divisor.ts @@ -1,7 +1,7 @@ import { tryParseInteger } from "@ark/util" import type { DynamicStateWithRoot } from "../../reduce/dynamic.ts" import type { StaticState, state } from "../../reduce/static.ts" -import type { Scanner } from "../scanner.ts" +import type { ArkTypeScanner } from "../scanner.ts" export const parseDivisor = (s: DynamicStateWithRoot): void => { const divisorToken = s.scanner.shiftUntilNextTerminator() @@ -14,9 +14,9 @@ export const parseDivisor = (s: DynamicStateWithRoot): void => { } export type parseDivisor = - Scanner.shiftUntilNextTerminator> extends ( - Scanner.shiftResult - ) ? + ArkTypeScanner.shiftUntilNextTerminator< + ArkTypeScanner.skipWhitespace + > extends ArkTypeScanner.shiftResult ? scanned extends `${infer divisor extends number}` ? divisor extends 0 ? state.error> diff --git a/ark/type/parser/shift/operator/operator.ts b/ark/type/parser/shift/operator/operator.ts index caf34da91f..d2a392c825 100644 --- a/ark/type/parser/shift/operator/operator.ts +++ b/ark/type/parser/shift/operator/operator.ts @@ -1,7 +1,7 @@ -import { isKeyOf, whiteSpaceTokens, type WhiteSpaceToken } from "@ark/util" +import { isKeyOf, whitespaceChars, type WhitespaceChar } from "@ark/util" import type { DynamicStateWithRoot } from "../../reduce/dynamic.ts" import type { StaticState, state } from "../../reduce/static.ts" -import { Scanner } from "../scanner.ts" +import { ArkTypeScanner } from "../scanner.ts" import { comparatorStartChars, parseBound, @@ -20,34 +20,36 @@ export const parseOperator = (s: DynamicStateWithRoot): void => { : s.error(incompleteArrayTokenMessage) : lookahead === "|" || lookahead === "&" ? s.pushRootToBranch(lookahead) : lookahead === ")" ? s.finalizeGroup() - : Scanner.lookaheadIsFinalizing(lookahead, s.scanner.unscanned) ? + : ArkTypeScanner.lookaheadIsFinalizing(lookahead, s.scanner.unscanned) ? s.finalize(lookahead) : isKeyOf(lookahead, comparatorStartChars) ? parseBound(s, lookahead) : lookahead === "%" ? parseDivisor(s) : lookahead === "#" ? parseBrand(s) - : lookahead in whiteSpaceTokens ? parseOperator(s) + : lookahead in whitespaceChars ? parseOperator(s) : s.error(writeUnexpectedCharacterMessage(lookahead)) ) } export type parseOperator = - s["unscanned"] extends Scanner.shift ? + s["unscanned"] extends ( + ArkTypeScanner.shift + ) ? lookahead extends "[" ? - unscanned extends Scanner.shift<"]", infer nextUnscanned> ? + unscanned extends ArkTypeScanner.shift<"]", infer nextUnscanned> ? state.setRoot : state.error : lookahead extends "|" | "&" ? state.reduceBranch : lookahead extends ")" ? state.finalizeGroup - : Scanner.lookaheadIsFinalizing extends true ? + : ArkTypeScanner.lookaheadIsFinalizing extends true ? state.finalize< state.scanTo, - lookahead & Scanner.FinalizingLookahead + lookahead & ArkTypeScanner.FinalizingLookahead > : lookahead extends ComparatorStartChar ? parseBound : lookahead extends "%" ? parseDivisor : lookahead extends "#" ? parseBrand - : lookahead extends WhiteSpaceToken ? + : lookahead extends WhitespaceChar ? parseOperator, $, args> : state.error> : state.finalize diff --git a/ark/type/parser/shift/scanner.ts b/ark/type/parser/shift/scanner.ts index 3aa5c11941..a5b0bb7365 100644 --- a/ark/type/parser/shift/scanner.ts +++ b/ark/type/parser/shift/scanner.ts @@ -1,126 +1,46 @@ import { - escapeToken, isKeyOf, - whiteSpaceTokens, - type Dict, - type EscapeToken, - type WhiteSpaceToken + Scanner, + whitespaceChars, + type EscapeChar, + type WhitespaceChar } from "@ark/util" import type { Comparator } from "../reduce/shared.ts" -export class Scanner { - private chars: string[] - private i: number - - constructor(def: string) { - this.chars = [...def] - this.i = 0 - } - - /** Get lookahead and advance scanner by one */ - shift(): lookahead { - return (this.chars[this.i++] ?? "") as never - } - - get lookahead(): lookahead { - return (this.chars[this.i] ?? "") as never - } - - get nextLookahead(): string { - return this.chars[this.i + 1] ?? "" - } - - get length(): number { - return this.chars.length - } - - shiftUntil(condition: Scanner.UntilCondition): string { - let shifted = "" - while (this.lookahead) { - if (condition(this, shifted)) { - if (shifted[shifted.length - 1] === escapeToken) - shifted = shifted.slice(0, -1) - else break - } - shifted += this.shift() - } - return shifted - } - +export class ArkTypeScanner< + lookahead extends string = string +> extends Scanner { shiftUntilNextTerminator(): string { this.shiftUntilNonWhitespace() - return this.shiftUntil(Scanner.lookaheadIsTerminator) - } - - shiftUntilNonWhitespace(): string { - return this.shiftUntil(Scanner.lookaheadIsNotWhitespace) - } - - jumpToIndex(i: number): void { - this.i = i < 0 ? this.length + i : i - } - - jumpForward(count: number): void { - this.i += count - } - - get location(): number { - return this.i - } - - get unscanned(): string { - return this.chars.slice(this.i, this.length).join("") - } - - get scanned(): string { - return this.chars.slice(0, this.i).join("") - } - - sliceChars(start: number, end?: number): string { - return this.chars.slice(start, end).join("") - } - - lookaheadIs(char: char): this is Scanner { - return this.lookahead === char - } - - lookaheadIsIn( - tokens: tokens - ): this is Scanner> { - return this.lookahead in tokens + return this.shiftUntil( + () => this.lookahead in ArkTypeScanner.terminatingChars + ) } static terminatingChars = { - "<": true, - ">": true, - "=": true, - "|": true, - "&": true, - ")": true, - "[": true, - "%": true, - ",": true, - ":": true, - "?": true, - "#": true, - ...whiteSpaceTokens + "<": 1, + ">": 1, + "=": 1, + "|": 1, + "&": 1, + ")": 1, + "[": 1, + "%": 1, + ",": 1, + ":": 1, + "?": 1, + "#": 1, + ...whitespaceChars } as const static finalizingLookaheads = { - ">": true, - ",": true, - "": true, - "=": true, - "?": true + ">": 1, + ",": 1, + "": 1, + "=": 1, + "?": 1 } as const - static lookaheadIsTerminator: Scanner.UntilCondition = (scanner: Scanner) => - scanner.lookahead in this.terminatingChars - - static lookaheadIsNotWhitespace: Scanner.UntilCondition = ( - scanner: Scanner - ) => !(scanner.lookahead in whiteSpaceTokens) - static lookaheadIsFinalizing = ( lookahead: string, unscanned: string @@ -132,7 +52,7 @@ export class Scanner { unscanned[1] === "=" // if > is the end of a generic instantiation, the next token will be an operator or the end of the string : unscanned.trimStart() === "" || - isKeyOf(unscanned.trimStart()[0], Scanner.terminatingChars) + isKeyOf(unscanned.trimStart()[0], ArkTypeScanner.terminatingChars) // "=" is a finalizer on its own (representing a default value), // but not with a second "=" (an equality comparator) : lookahead === "=" ? unscanned[0] !== "=" @@ -140,7 +60,7 @@ export class Scanner { : lookahead === "," || lookahead === "?" } -export declare namespace Scanner { +export declare namespace ArkTypeScanner { export type lookaheadIsFinalizing< lookahead extends string, unscanned extends string @@ -150,7 +70,7 @@ export declare namespace Scanner { nextUnscanned extends `=${string}` ? true : false - : Scanner.skipWhitespace extends ( + : ArkTypeScanner.skipWhitespace extends ( "" | `${TerminatingChar}${string}` ) ? true @@ -162,17 +82,10 @@ export declare namespace Scanner { : lookahead extends "," | "?" ? true : false - export type UntilCondition = (scanner: Scanner, shifted: string) => boolean - - export type OnInputEndFn = (scanner: Scanner, shifted: string) => string - - export type ShiftUntilOptions = { - onInputEnd?: OnInputEndFn - } - - export type TerminatingChar = keyof typeof Scanner.terminatingChars + export type TerminatingChar = keyof typeof ArkTypeScanner.terminatingChars - export type FinalizingLookahead = keyof typeof Scanner.finalizingLookaheads + export type FinalizingLookahead = + keyof typeof ArkTypeScanner.finalizingLookaheads export type InfixToken = | Comparator @@ -201,7 +114,7 @@ export declare namespace Scanner { > = unscanned extends shift ? lookahead extends terminator ? - scanned extends `${infer base}${EscapeToken}` ? + scanned extends `${infer base}${EscapeChar}` ? shiftUntil : [scanned, unscanned] : shiftUntil @@ -225,7 +138,7 @@ export declare namespace Scanner { export type skipWhitespace = shiftUntilNot< unscanned, - WhiteSpaceToken + WhitespaceChar >[1] export type shiftResult = [ diff --git a/ark/type/parser/tuple.ts b/ark/type/parser/tuple.ts index bc9e99a6e2..650103e00d 100644 --- a/ark/type/parser/tuple.ts +++ b/ark/type/parser/tuple.ts @@ -43,7 +43,7 @@ import type { type } from "../keywords/keywords.ts" import type { PostfixExpression } from "./ast/infer.ts" import type { inferDefinition, validateDefinition } from "./definition.ts" import { writeMissingRightOperandMessage } from "./shift/operand/unenclosed.ts" -import type { Scanner } from "./shift/scanner.ts" +import type { ArkTypeScanner } from "./shift/scanner.ts" import type { BaseCompletions } from "./string.ts" export const parseTuple = (def: array, ctx: BaseParseContext): BaseRoot => @@ -178,7 +178,11 @@ const maybeParseTupleExpression = ( // It is *extremely* important we use readonly any time we check a tuple against // something like this. Not doing so will always cause the check to fail, since // def is declared as a const parameter. -type InfixExpression = readonly [unknown, Scanner.InfixToken, ...unknown[]] +type InfixExpression = readonly [ + unknown, + ArkTypeScanner.InfixToken, + ...unknown[] +] export type validateTuple = def extends IndexZeroExpression ? validatePrefixExpression diff --git a/ark/type/scope.ts b/ark/type/scope.ts index e64946dd5f..2bb8f8108f 100644 --- a/ark/type/scope.ts +++ b/ark/type/scope.ts @@ -68,7 +68,7 @@ import { } from "./parser/definition.ts" import { DynamicState } from "./parser/reduce/dynamic.ts" import { writeUnexpectedCharacterMessage } from "./parser/shift/operator/operator.ts" -import { Scanner } from "./parser/shift/scanner.ts" +import { ArkTypeScanner } from "./parser/shift/scanner.ts" import { fullStringParse } from "./parser/string.ts" import { InternalTypeParser, @@ -237,7 +237,7 @@ export class InternalScope<$ extends {} = {}> extends BaseScope<$> { opts: BaseParseOptions ): array { return parseGenericParamName( - new Scanner(def), + new ArkTypeScanner(def), [], this.createParseContext({ ...opts, def, prefix: "generic" }) ) @@ -292,7 +292,7 @@ export class InternalScope<$ extends {} = {}> extends BaseScope<$> { if (aliasArrayResolution) return aliasArrayResolution - const s = new DynamicState(new Scanner(def), ctx) + const s = new DynamicState(new ArkTypeScanner(def), ctx) const node = fullStringParse(s) diff --git a/ark/util/arrays.ts b/ark/util/arrays.ts index c2f9c195bc..e14c6bb8fb 100644 --- a/ark/util/arrays.ts +++ b/ark/util/arrays.ts @@ -184,8 +184,7 @@ export const append = < return ( value === undefined ? [] : Array.isArray(value) ? value - : ([value] as any) - ) + : [value]) as never } if (opts?.prepend) { diff --git a/ark/util/index.ts b/ark/util/index.ts index c814806d23..91c2ebaaa7 100644 --- a/ark/util/index.ts +++ b/ark/util/index.ts @@ -12,9 +12,11 @@ export * from "./keys.ts" export * from "./lazily.ts" export * from "./numbers.ts" export * from "./objectKinds.ts" +export * from "./path.ts" export * from "./primitive.ts" export * from "./records.ts" export * from "./registry.ts" +export * from "./scanner.ts" export * from "./serialize.ts" export * from "./strings.ts" export * from "./traits.ts" diff --git a/ark/util/package.json b/ark/util/package.json index 3103132103..f61c27b380 100644 --- a/ark/util/package.json +++ b/ark/util/package.json @@ -1,6 +1,6 @@ { "name": "@ark/util", - "version": "0.21.0", + "version": "0.22.0", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/util/path.ts b/ark/util/path.ts new file mode 100644 index 0000000000..f3fbb424ed --- /dev/null +++ b/ark/util/path.ts @@ -0,0 +1,96 @@ +import { ReadonlyArray, type array } from "./arrays.ts" +import { throwParseError } from "./errors.ts" +import type { requireKeys } from "./records.ts" +import { isDotAccessible } from "./registry.ts" +import { printable } from "./serialize.ts" + +export type StringifyPathOptions = requireKeys< + { + stringifySymbol?: (s: symbol) => string + stringifyNonKey?: (o: Exclude) => string + }, + stringifiable extends PropertyKey ? never : "stringifyNonKey" +> + +export type StringifyPathFn = ( + path: array, + ...[opts]: [stringifiable] extends [PropertyKey] ? + [opts?: StringifyPathOptions] + : NoInfer<[opts: StringifyPathOptions]> +) => string + +export type AppendStringifiedKeyFn = ( + path: string, + prop: stringifiable, + ...[opts]: [stringifiable] extends [PropertyKey] ? + [opts?: StringifyPathOptions] + : NoInfer<[opts: StringifyPathOptions]> +) => string + +export const appendStringifiedKey: AppendStringifiedKeyFn = ( + path, + prop, + ...[opts] +) => { + const stringifySymbol = opts?.stringifySymbol ?? printable + let propAccessChain: string = path + switch (typeof prop) { + case "string": + propAccessChain = + isDotAccessible(prop) ? + path === "" ? + prop + : `${path}.${prop}` + : `${path}[${JSON.stringify(prop)}]` + break + case "number": + propAccessChain = `${path}[${prop}]` + break + case "symbol": + propAccessChain = `${path}[${stringifySymbol(prop)}]` + break + default: + if (opts?.stringifyNonKey) + propAccessChain = `${path}[${opts.stringifyNonKey(prop as never)}]` + else { + throwParseError( + `${printable(prop)} must be a PropertyKey or stringifyNonKey must be passed to options` + ) + } + } + return propAccessChain +} + +export const stringifyPath: StringifyPathFn = (path, ...opts) => + path.reduce((s, k) => appendStringifiedKey(s, k, ...opts), "") + +export class ReadonlyPath extends ReadonlyArray { + // alternate strategy for caching since the base object is frozen + private cache: { + stringify?: string + stringifyAncestors?: readonly string[] + } = {} + + constructor(items: array) { + super() + // avoid case where a single number will create empty slots + ;(this as any).push(...items) + Object.freeze(this) + } + + stringify(): string { + if (this.cache.stringify) return this.cache.stringify + return (this.cache.stringify = stringifyPath(this)) + } + + stringifyAncestors(): readonly string[] { + if (this.cache.stringifyAncestors) return this.cache.stringifyAncestors + let propString = "" + const result: string[] = [propString] + this.forEach(path => { + propString = appendStringifiedKey(propString, path) + result.push(propString) + }) + return (this.cache.stringifyAncestors = result) + } +} diff --git a/ark/util/records.ts b/ark/util/records.ts index d1333e94f6..c1513a658b 100644 --- a/ark/util/records.ts +++ b/ark/util/records.ts @@ -47,9 +47,9 @@ export type PartialRecord = { export type isSafelyMappable = { [k in keyof t]: t[k] } extends t ? true : false -export type keySet = { readonly [_ in key]?: 1 } +export type KeySet = { readonly [_ in key]?: 1 } -export type keySetOf = keySet> +export type keySetOf = KeySet> export type mutable = _mutable diff --git a/ark/util/registry.ts b/ark/util/registry.ts index 1fb88ce349..973bcbd595 100644 --- a/ark/util/registry.ts +++ b/ark/util/registry.ts @@ -7,7 +7,7 @@ import { FileConstructor, objectKindOf } from "./objectKinds.ts" // recent node versions (https://nodejs.org/api/esm.html#json-modules). // For now, we assert this matches the package.json version via a unit test. -export const arkUtilVersion = "0.21.0" +export const arkUtilVersion = "0.22.0" export const initialRegistryContents = { version: arkUtilVersion, diff --git a/ark/util/scanner.ts b/ark/util/scanner.ts new file mode 100644 index 0000000000..1a37d3f4aa --- /dev/null +++ b/ark/util/scanner.ts @@ -0,0 +1,92 @@ +import type { KeySet } from "./records.ts" +import { escapeChar, whitespaceChars } from "./strings.ts" + +export class Scanner { + chars: string[] + i: number + def: string + + constructor(def: string) { + this.def = def + this.chars = [...def] + this.i = 0 + } + + /** Get lookahead and advance scanner by one */ + shift(): this["lookahead"] { + return (this.chars[this.i++] ?? "") as never + } + + get lookahead(): lookahead { + return (this.chars[this.i] ?? "") as never + } + + get nextLookahead(): string { + return this.chars[this.i + 1] ?? "" + } + + get length(): number { + return this.chars.length + } + + shiftUntil(condition: Scanner.UntilCondition): string { + let shifted = "" + while (this.lookahead) { + if (condition(this, shifted)) { + if (shifted[shifted.length - 1] === escapeChar) + shifted = shifted.slice(0, -1) + else break + } + shifted += this.shift() + } + return shifted + } + + shiftUntilLookahead(charOrSet: string | KeySet): string { + return typeof charOrSet === "string" ? + this.shiftUntil(s => s.lookahead === charOrSet) + : this.shiftUntil(s => s.lookahead in charOrSet) + } + + shiftUntilNonWhitespace(): string { + return this.shiftUntil(() => !(this.lookahead in whitespaceChars)) + } + + jumpToIndex(i: number): void { + this.i = i < 0 ? this.length + i : i + } + + jumpForward(count: number): void { + this.i += count + } + + get location(): number { + return this.i + } + + get unscanned(): string { + return this.chars.slice(this.i, this.length).join("") + } + + get scanned(): string { + return this.chars.slice(0, this.i).join("") + } + + sliceChars(start: number, end?: number): string { + return this.chars.slice(start, end).join("") + } + + lookaheadIs(char: char): this is Scanner { + return this.lookahead === char + } + + lookaheadIsIn( + tokens: keySet + ): this is Scanner> { + return this.lookahead in tokens + } +} + +export declare namespace Scanner { + export type UntilCondition = (scanner: Scanner, shifted: string) => boolean +} diff --git a/ark/util/strings.ts b/ark/util/strings.ts index 78f826155a..cfb201fa79 100644 --- a/ark/util/strings.ts +++ b/ark/util/strings.ts @@ -1,3 +1,5 @@ +import type { KeySet } from "./records.ts" + export const capitalize = (s: s): Capitalize => (s[0].toUpperCase() + s.slice(1)) as never @@ -53,25 +55,25 @@ export const deanchoredSource = (regex: RegExp | string): string => { ) } -export const escapeToken = "\\" +export const escapeChar = "\\" -export type EscapeToken = typeof escapeToken +export type EscapeChar = typeof escapeChar -export const whiteSpaceTokens = { - " ": true, - "\n": true, - "\t": true -} as const +export const whitespaceChars = { + " ": 1, + "\n": 1, + "\t": 1 +} as const satisfies KeySet -export type WhiteSpaceToken = keyof typeof whiteSpaceTokens +export type WhitespaceChar = keyof typeof whitespaceChars export type trim = trimEnd> export type trimStart = - s extends `${WhiteSpaceToken}${infer tail}` ? trimEnd : s + s extends `${WhitespaceChar}${infer tail}` ? trimEnd : s export type trimEnd = - s extends `${infer init}${WhiteSpaceToken}` ? trimEnd : s + s extends `${infer init}${WhitespaceChar}` ? trimEnd : s // Credit to @gugaguichard for this! https://x.com/gugaguichard/status/1720528864500150534 export type isStringLiteral =