From 5fe79c6c8db94f20c997c7a8960edb9d69468b69 Mon Sep 17 00:00:00 2001 From: David Blass Date: Mon, 24 Jun 2024 18:16:00 -0400 Subject: [PATCH] feat: more precise string default parsing, improved cyclic union resolutions (#1028) --- .changeset/wet-suns-serve.md | 7 + ark/attest/package.json | 2 +- ark/schema/generic.ts | 3 +- ark/schema/index.ts | 1 + ark/schema/roots/alias.ts | 4 +- ark/schema/scope.ts | 42 ++- ark/schema/shared/intersections.ts | 16 +- ark/schema/shared/traversal.ts | 2 +- ark/type/CHANGELOG.md | 19 ++ ark/type/__tests__/bounds.test.ts | 11 +- ark/type/__tests__/defaults.test.ts | 39 ++- ark/type/__tests__/objectLiteral.test.ts | 5 +- ark/type/__tests__/operand.bench.ts | 52 +-- ark/type/__tests__/realWorld.test.ts | 44 +++ ark/type/package.json | 2 +- ark/type/parser/generic.ts | 8 +- ark/type/parser/objectLiteral.ts | 320 ++++++------------ ark/type/parser/semantic/default.ts | 27 ++ ark/type/parser/semantic/infer.ts | 8 + ark/type/parser/semantic/validate.ts | 6 + ark/type/parser/string/reduce/dynamic.ts | 5 +- .../parser/string/shift/operand/operand.ts | 8 +- .../parser/string/shift/operator/bounds.ts | 12 +- .../parser/string/shift/operator/default.ts | 64 ++++ .../parser/string/shift/operator/operator.ts | 4 +- ark/type/parser/string/shift/scanner.ts | 39 ++- ark/type/parser/string/string.ts | 16 +- ark/type/parser/tuple.ts | 5 +- ark/type/scope.ts | 46 ++- ark/util/arrays.ts | 11 +- ark/util/strings.ts | 20 ++ package.json | 2 +- 32 files changed, 487 insertions(+), 363 deletions(-) create mode 100644 .changeset/wet-suns-serve.md create mode 100644 ark/type/parser/semantic/default.ts create mode 100644 ark/type/parser/string/shift/operator/default.ts diff --git a/.changeset/wet-suns-serve.md b/.changeset/wet-suns-serve.md new file mode 100644 index 0000000000..be55e30ae3 --- /dev/null +++ b/.changeset/wet-suns-serve.md @@ -0,0 +1,7 @@ +--- +"@arktype/attest": patch +"@arktype/schema": patch +"@arktype/util": patch +--- + +Bump version diff --git a/ark/attest/package.json b/ark/attest/package.json index b8a2545451..01c4185e87 100644 --- a/ark/attest/package.json +++ b/ark/attest/package.json @@ -25,7 +25,7 @@ "bunTest": "bun test --preload ../repo/bunTestSetup.ts" }, "dependencies": { - "arktype": "latest", + "arktype": "*", "@arktype/fs": "workspace:*", "@arktype/util": "workspace:*", "@typescript/vfs": "1.5.3", diff --git a/ark/schema/generic.ts b/ark/schema/generic.ts index da7caf073f..e778613e5d 100644 --- a/ark/schema/generic.ts +++ b/ark/schema/generic.ts @@ -20,7 +20,6 @@ export type GenericNodeInstantiation< ...args: conform> ) => Root>> -// TODO: ???? export type bindGenericNodeInstantiation = { [i in keyof params & `${number}` as params[i]]: inferRoot< args[i & keyof args], @@ -63,7 +62,7 @@ export class GenericRoot }) } - bindScope($: RawRootScope) { + bindScope($: RawRootScope): never { throw new Error(`Unimplemented generic bind ${$}`) } diff --git a/ark/schema/index.ts b/ark/schema/index.ts index 071a4edc83..3dd1757acc 100644 --- a/ark/schema/index.ts +++ b/ark/schema/index.ts @@ -41,6 +41,7 @@ export * from "./shared/utils.js" export * from "./structure/indexed.js" export * from "./structure/optional.js" export * from "./structure/prop.js" +export * from "./structure/required.js" export * from "./structure/sequence.js" export * from "./structure/structure.js" diff --git a/ark/schema/roots/alias.ts b/ark/schema/roots/alias.ts index 192c4709ec..a6ec1fd500 100644 --- a/ark/schema/roots/alias.ts +++ b/ark/schema/roots/alias.ts @@ -47,14 +47,14 @@ export class AliasNode extends BaseRoot { traverseAllows: TraverseAllows = (data, ctx) => { const seen = ctx.seen[this.id] - if (seen?.includes(data as object)) return true + if (seen?.includes(data)) return true ctx.seen[this.id] = append(seen, data) return this.resolution.traverseAllows(data, ctx) } traverseApply: TraverseApply = (data, ctx) => { const seen = ctx.seen[this.id] - if (seen?.includes(data as object)) return + if (seen?.includes(data)) return ctx.seen[this.id] = append(seen, data) this.resolution.traverseApply(data, ctx) } diff --git a/ark/schema/scope.ts b/ark/schema/scope.ts index d08454891a..429bb8fe82 100644 --- a/ark/schema/scope.ts +++ b/ark/schema/scope.ts @@ -150,22 +150,7 @@ export class RawRootScope<$ extends RawRootResolutions = RawRootResolutions> // ensure exportedResolutions is populated $ark.ambient.export() // TODO: generics and modules - this.resolutions = flatMorph( - $ark.ambient.resolutions, - (alias, resolution) => - // an alias defined in this scope should override an ambient alias of the same name - alias in this.aliases ? - [] - : [ - alias, - ( - hasArkKind(resolution, "root") || - hasArkKind(resolution, "generic") - ) ? - resolution.internal - : resolution - ] - ) + this.resolutions = {} } scopesById[this.id] = this } @@ -310,15 +295,28 @@ export class RawRootScope<$ extends RawRootResolutions = RawRootResolutions> maybeShallowResolve(name: string): CachedResolution | undefined { const cached = this.resolutions[name] if (cached) return cached - let def = this.aliases[name] + const def = this.aliases[name] ?? $ark.ambient.resolutions[name] + if (!def) return this.maybeResolveSubalias(name) - def = this.preparseRoot(def) - if (hasArkKind(def, "generic")) - return (this.resolutions[name] = def.validateBaseInstantiation()) - if (hasArkKind(def, "module")) return (this.resolutions[name] = def) + const preparsed = this.preparseRoot(def) + if (hasArkKind(preparsed, "generic")) { + return (this.resolutions[name] = preparsed + .validateBaseInstantiation() + ?.bindScope(this)) + } + + if (hasArkKind(preparsed, "module")) { + return (this.resolutions[name] = new RootModule( + flatMorph(preparsed, (alias, node) => [ + alias, + (node as BaseRoot | GenericRoot).bindScope(this) + ]) + )) + } + this.resolutions[name] = name - return (this.resolutions[name] = this.parseRoot(def)) + return (this.resolutions[name] = this.parseRoot(preparsed).bindScope(this)) } /** If name is a valid reference to a submodule alias, return its resolution */ diff --git a/ark/schema/shared/intersections.ts b/ark/schema/shared/intersections.ts index 4a184a1bff..a6d7940900 100644 --- a/ark/schema/shared/intersections.ts +++ b/ark/schema/shared/intersections.ts @@ -140,12 +140,24 @@ export const intersectNodes: InternalNodeIntersection = ( let result: UnknownIntersectionResult - if (ctx.pipe && l.hasKind("morph")) { + if (ctx.pipe && l.includesMorph) { + if (!l.hasKind("morph")) { + return ctx.$.node("morph", { + morphs: [r], + in: l + }) + } result = ctx.invert ? pipeToMorph(r as never, l, ctx) : pipeFromMorph(l, r as never, ctx) - } else if (ctx.pipe && r.hasKind("morph")) { + } else if (ctx.pipe && r.includesMorph) { + if (!r.hasKind("morph")) { + return ctx.$.node("morph", { + morphs: [r], + in: l + }) + } result = ctx.invert ? pipeFromMorph(r, l as never, ctx) diff --git a/ark/schema/shared/traversal.ts b/ark/schema/shared/traversal.ts index 4f2a0ba55b..f419e7a880 100644 --- a/ark/schema/shared/traversal.ts +++ b/ark/schema/shared/traversal.ts @@ -26,7 +26,7 @@ export class TraversalContext { errors: ArkErrors = new ArkErrors(this) branches: BranchTraversalContext[] = [] - seen: { [id in string]?: object[] } = {} + seen: { [id in string]?: unknown[] } = {} constructor( public root: unknown, diff --git a/ark/type/CHANGELOG.md b/ark/type/CHANGELOG.md index 305ac27bbd..0dbbfcd198 100644 --- a/ark/type/CHANGELOG.md +++ b/ark/type/CHANGELOG.md @@ -1,5 +1,24 @@ # arktype +## 2.0.0-dev.26 + +### Improved string default parsing + +String defaults are now parsed more efficiently by the core string parser. They can include arbitrary whitespace and give more specific errors. + +### Fix a resolution issue on certain cyclic unions + +```ts +// Now resolves correctly +const types = scope({ + TypeWithKeywords: "ArraySchema", + Schema: "number|ArraySchema", + ArraySchema: { + "additionalItems?": "Schema" + } +}).export() +``` + ## 2.0.0-dev.25 ### String defaults diff --git a/ark/type/__tests__/bounds.test.ts b/ark/type/__tests__/bounds.test.ts index 085581887c..4bad771bc7 100644 --- a/ark/type/__tests__/bounds.test.ts +++ b/ark/type/__tests__/bounds.test.ts @@ -14,10 +14,8 @@ import { writeOpenRangeMessage, writeUnpairableComparatorMessage } from "../parser/string/reduce/shared.js" -import { - singleEqualsMessage, - writeInvalidLimitMessage -} from "../parser/string/shift/operator/bounds.js" +import { writeInvalidLimitMessage } from "../parser/string/shift/operator/bounds.js" +import { shallowDefaultMessage } from "../parser/string/shift/operator/default.js" contextualize( "string expressions", @@ -155,11 +153,6 @@ contextualize( attest(t.allows(new Date(now.valueOf() + 1000))).equals(false) }) - it("single equals", () => { - // @ts-expect-error - attest(() => type("string=5")).throwsAndHasTypeError(singleEqualsMessage) - }) - it("invalid left comparator", () => { // @ts-expect-error attest(() => type("3>number<5")).throwsAndHasTypeError( diff --git a/ark/type/__tests__/defaults.test.ts b/ark/type/__tests__/defaults.test.ts index d717625031..0cb3c7bf50 100644 --- a/ark/type/__tests__/defaults.test.ts +++ b/ark/type/__tests__/defaults.test.ts @@ -2,7 +2,10 @@ import { attest, contextualize } from "@arktype/attest" import type { Date, Default } from "@arktype/schema" import { scope, type } from "arktype" import { invalidDefaultKeyKindMessage } from "../parser/objectLiteral.js" -import { singleEqualsMessage } from "../parser/string/shift/operator/bounds.js" +import { + shallowDefaultMessage, + writeNonLiteralDefaultMessage +} from "../parser/string/shift/operator/default.js" contextualize( "parsing and traversal", @@ -41,7 +44,7 @@ contextualize( .throws.snap( 'ParseError: Default value at "bar" must be a number (was string)' ) - .type.errors("Type 'string' is not assignable to type 'number'") + .type.errors() }) it("optional with default", () => { @@ -155,16 +158,31 @@ contextualize( attest(() => // @ts-expect-error type({ foo: "string", bar: "unknown = number" }) - ).throwsAndHasTypeError(singleEqualsMessage) + ).throwsAndHasTypeError(writeNonLiteralDefaultMessage("number")) }) - // https://github.com/arktypeio/arktype/issues/1017 - // it("validated default in scope", () => { - // const $ = scope({ - // specialNumber: "number", - // obj: { foo: "string", bar: "specialNumber =5" } - // }) - // }) + it("validated default in scope", () => { + const $ = scope({ + specialNumber: "number", + obj: { foo: "string", bar: "specialNumber = 5" } + }) + + $.export() + + attest($.json).snap({ + specialNumber: { domain: "number" }, + obj: { + required: [{ key: "foo", value: "string" }], + optional: [{ default: 5, key: "bar", value: "number" }], + domain: "object" + } + }) + }) + + it("shallow default", () => { + // would be ideal if this was a type error as well + attest(() => type("string='foo'")).throws(shallowDefaultMessage) + }) it("optional with default", () => { attest(() => @@ -185,6 +203,7 @@ contextualize( domain: "object" }) }) + it("same default", () => { const l = type({ bar: ["number", "=", 5] }) const r = type({ bar: ["5", "=", 5] }) diff --git a/ark/type/__tests__/objectLiteral.test.ts b/ark/type/__tests__/objectLiteral.test.ts index be3843fb15..b9ac74a2db 100644 --- a/ark/type/__tests__/objectLiteral.test.ts +++ b/ark/type/__tests__/objectLiteral.test.ts @@ -10,6 +10,7 @@ import { writeInvalidSpreadTypeMessage, writeInvalidUndeclaredBehaviorMessage } from "../parser/objectLiteral.js" +import { writeUnexpectedCharacterMessage } from "../parser/string/shift/operator/operator.js" contextualize( "named", @@ -412,7 +413,9 @@ other must be a string (was bigint)`) // @ts-expect-error "[unresolvable]": "'unresolvable' is unresolvable" }) - ).throwsAndHasTypeError(writeUnresolvableMessage("unresolvable")) + ) + .throws(writeUnexpectedCharacterMessage("i")) + .type.errors(writeUnresolvableMessage("unresolvable")) }) it("semantic error in index definition", () => { diff --git a/ark/type/__tests__/operand.bench.ts b/ark/type/__tests__/operand.bench.ts index d8aec3e8b3..544a42b030 100644 --- a/ark/type/__tests__/operand.bench.ts +++ b/ark/type/__tests__/operand.bench.ts @@ -1,45 +1,45 @@ import { bench } from "@arktype/attest" -import { scope, type } from "arktype" +import { type } from "arktype" bench("single-quoted", () => { const _ = type("'nineteen characters'") -}) - .median([3.05, "us"]) - .types([502, "instantiations"]) +}).types([2697, "instantiations"]) bench("double-quoted", () => { const _ = type('"nineteen characters"') -}) - .median([3.13, "us"]) - .types([502, "instantiations"]) +}).types([2697, "instantiations"]) bench("regex literal", () => { const _ = type("/nineteen characters/") -}) - .median([4.18, "us"]) - .types([502, "instantiations"]) +}).types([2741, "instantiations"]) bench("keyword", () => { const _ = type("string") -}) - .median([1.44, "us"]) - .types([84, "instantiations"]) - -const $ = scope({ strung: "string" }) -bench("alias", () => { - const _ = $.type("strung") -}) - .median([1.54, "us"]) - .types([725, "instantiations"]) +}).types([2507, "instantiations"]) bench("number", () => { const _ = type("-98765.4321") -}) - .median([4.41, "us"]) - .types([415, "instantiations"]) +}).types([2589, "instantiations"]) bench("bigint", () => { const _ = type("-987654321n") -}) - .median() - .types() +}).types([2611, "instantiations"]) + +bench("instantiations", () => { + const t = type({ foo: "string" }) +}).types([3522, "instantiations"]) + +bench("union", () => { + // Union is automatically discriminated using shallow or deep keys + const user = type({ + kind: "'admin'", + "powers?": "string[]" + }) + .or({ + kind: "'superadmin'", + "superpowers?": "string[]" + }) + .or({ + kind: "'pleb'" + }) +}).types([8430, "instantiations"]) diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index fe5c76861b..86495e3b9c 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -618,4 +618,48 @@ nospace must be matched by ^\\S*$ (was "One space")`) attest(u.t).type.toString("(In: string) => Out<`${string}++`>") }) + + it("recursive reference from union", () => { + const $ = scope({ + TypeWithKeywords: "ArraySchema", + Schema: "number|ArraySchema", + ArraySchema: { + "additionalItems?": "Schema" + } + }) + + $.export() + + attest($.json).snap({ + TypeWithKeywords: { + optional: [ + { key: "additionalItems", value: ["$ArraySchema", "number"] } + ], + domain: "object" + }, + Schema: ["$ArraySchema", "number"], + ArraySchema: { + optional: [ + { key: "additionalItems", value: ["$ArraySchema", "number"] } + ], + domain: "object" + } + }) + }) + + // https://discord.com/channels/957797212103016458/957804102685982740/1254900389346807849 + it("narrows nested morphs", () => { + const parseBigint = type("string").pipe(s => BigInt(s)) + const fee = type({ amount: parseBigint }).narrow( + fee => typeof fee.amount === "bigint" + ) + + const Claim = type({ + fee + }) + + const out = Claim.assert({ fee: { amount: "5" } }) + + attest(out).equals({ fee: { amount: 5n } }) + }) }) diff --git a/ark/type/package.json b/ark/type/package.json index e865b43f54..900ee0698a 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-dev.25", + "version": "2.0.0-dev.26", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/type/parser/generic.ts b/ark/type/parser/generic.ts index 248096ce99..e4ac238815 100644 --- a/ark/type/parser/generic.ts +++ b/ark/type/parser/generic.ts @@ -1,4 +1,8 @@ -import { type keyError, throwParseError } from "@arktype/util" +import { + type keyError, + throwParseError, + type WhiteSpaceToken +} from "@arktype/util" import { writeUnexpectedCharacterMessage } from "./string/shift/operator/operator.js" import { Scanner } from "./string/shift/scanner.js" @@ -47,7 +51,7 @@ type _parseParams< > = unscanned extends `${infer lookahead}${infer nextUnscanned}` ? lookahead extends "," ? _parseParams - : lookahead extends Scanner.WhiteSpaceToken ? + : lookahead extends WhiteSpaceToken ? param extends "" ? // if the next char is whitespace and we aren't in the middle of a param, skip to the next one _parseParams, "", result> diff --git a/ark/type/parser/objectLiteral.ts b/ark/type/parser/objectLiteral.ts index 6dfe3c145c..23a24ff5cb 100644 --- a/ark/type/parser/objectLiteral.ts +++ b/ark/type/parser/objectLiteral.ts @@ -2,50 +2,41 @@ import { ArkErrors, normalizeIndex, type BaseRoot, - type DateLiteral, type Default, - type distillOut, - type MutableInner, + type DefaultableAst, + type IndexNode, type NodeSchema, type of, - type PropKind, + type OptionalNode, + type RequiredNode, type StructureNode, type UndeclaredKeyBehavior, type writeInvalidPropertyKeyMessage } from "@arktype/schema" import { - anchoredRegex, append, - deanchoredSource, - integerLikeMatcher, + escapeToken, isArray, - keysOf, - numberLikeMatcher, printable, stringAndSymbolicEntriesOf, - throwInternalError, throwParseError, - tryParseWellFormedBigint, - tryParseWellFormedNumber, - unset, type anyOrNever, - type BigintLiteral, type Dict, type ErrorMessage, + type EscapeToken, type Key, type keyError, + type listable, type merge, type mutable, - type NumberLiteral, type show } from "@arktype/util" import type { ParseContext } from "../scope.js" import type { inferDefinition, validateDefinition } from "./definition.js" +import { writeUnassignableDefaultValueMessage } from "./semantic/default.js" import type { astToString } from "./semantic/utils.js" import type { validateString } from "./semantic/validate.js" -import type { StringLiteral } from "./string/shift/operand/enclosed.js" -import { Scanner } from "./string/shift/scanner.js" -import type { inferString } from "./string/string.js" +import type { ParsedDefault } from "./string/shift/operator/default.js" export const parseObjectLiteral = (def: Dict, ctx: ParseContext): BaseRoot => { let spread: StructureNode | undefined @@ -54,65 +45,30 @@ export const parseObjectLiteral = (def: Dict, ctx: ParseContext): BaseRoot => { // because to match JS behavior any keys before the spread are overwritten // by the values in the target object, so there'd be no useful purpose in having it // anywhere except for the beginning. - const parsedEntries = stringAndSymbolicEntriesOf(def).map(entry => - parseEntry(entry[0], entry[1]) + const parsedEntries = stringAndSymbolicEntriesOf(def).flatMap(entry => + parseEntry(entry[0], entry[1], ctx) ) - if (parsedEntries[0]?.kind === "...") { + if (parsedEntries[0]?.kind === "spread") { // remove the spread entry so we can iterate over the remaining entries // expecting non-spread entries - const spreadEntry = parsedEntries.shift()! - const spreadNode = ctx.$.parse(spreadEntry.value, ctx) - if (!spreadNode.hasKind("intersection") || !spreadNode.structure) { + const spreadEntry = parsedEntries.shift()! as ParsedSpreadEntry + if ( + !spreadEntry.node.hasKind("intersection") || + !spreadEntry.node.structure + ) { return throwParseError( - writeInvalidSpreadTypeMessage( - typeof spreadEntry.value === "string" ? - spreadEntry.value - : printable(spreadEntry.value) - ) + writeInvalidSpreadTypeMessage(typeof spreadEntry.node.expression) ) } - spread = spreadNode.structure + spread = spreadEntry.node.structure } for (const entry of parsedEntries) { - if (entry.kind === "...") return throwParseError(nonLeadingSpreadError) - if (entry.kind === "+") { - if ( - entry.value !== "reject" && - entry.value !== "delete" && - entry.value !== "ignore" - ) - throwParseError(writeInvalidUndeclaredBehaviorMessage(entry.value)) - structure.undeclared = entry.value + if (entry.kind === "spread") return throwParseError(nonLeadingSpreadError) + if (entry.kind === "undeclared") { + structure.undeclared = entry.behavior continue } - if (entry.kind === "index") { - // handle key parsing first to match type behavior - const key = ctx.$.parse(entry.key, ctx) - const value = ctx.$.parse(entry.value, ctx) - - const normalizedSignature = normalizeIndex(key, value, ctx.$) - if (normalizedSignature.required) { - structure.required = append( - structure.required, - normalizedSignature.required - ) - } - if (normalizedSignature.index) - structure.index = append(structure.index, normalizedSignature.index) - } else { - const value = ctx.$.parse(entry.value, ctx) - const inner: MutableInner = { key: entry.key, value } - if (entry.default !== unset) { - const out = value.traverse(entry.default) - if (out instanceof ArkErrors) - throwParseError(`Default value at ${printable(entry.key)} ${out}`) - - value.assert(entry.default) - ;(inner as MutableInner<"optional">).default = entry.default - } - - structure[entry.kind] = append(structure[entry.kind], inner) - } + structure[entry.kind] = append(structure[entry.kind], entry) as never } const structureNode = ctx.$.node("structure", structure) @@ -153,10 +109,6 @@ type _inferObjectLiteral = { def[k] extends anyOrNever ? def[k] : (In?: inferDefinition) => Default - : def[k] extends DefaultValueString ? - ( - In?: inferDefinition - ) => Default> : inferDefinition } & { -readonly [k in keyof def as optionalKeyFrom]?: inferDefinition< @@ -189,8 +141,12 @@ export type validateObjectLiteral = { type validateDefaultableValue = def[k] extends DefaultValueTuple ? validateDefaultValueTuple - : def[k] extends DefaultValueString ? - validateDefaultValueString + : validateDefinition extends def[k] ? + [inferDefinition, parseKey["kind"]] extends ( + [DefaultableAst, Exclude] + ) ? + ErrorMessage + : validateDefinition : validateDefinition type DefaultValueTuple = readonly [ @@ -213,44 +169,12 @@ type validateDefaultValueTuple< ] : ErrorMessage -type DefaultValueString< - baseDef extends string = string, - defaultDef extends UnitLiteral = UnitLiteral -> = `${baseDef}${typeof defaultValueStringOperator}${defaultDef}` - -const defaultValueStringOperator = " = " - -type validateDefaultValueString< - def extends DefaultValueString, - k extends PropertyKey, - $, - args -> = - def extends DefaultValueString ? - parseKey["kind"] extends "required" ? - validateDefinition extends ( - infer e extends ErrorMessage - ) ? - e - : [ - // check against the output of the type since morphs will not occur - // we currently can't parse string embedded defaults for non-global keywords - distillOut>, - // a default value should never have In/Out, so which side we choose is irrelevant - // we will never need a scope here as we're just trying to infer a UnitLiteral - distillOut> - ] extends [infer base, infer defaultValue] ? - defaultValue extends base ? - def - : ErrorMessage<`${defaultDef} is not assignable to ${baseDef}`> - : never - : ErrorMessage - : never - type nonOptionalKeyFrom = parseKey extends PreparsedKey<"required", infer inner> ? inner : parseKey extends PreparsedKey<"index", infer inner> ? - Extract, Key> + inferDefinition extends infer t extends Key ? + t + : never : // "..." is handled at the type root so is handled neither here nor in optionalKeyFrom // "+" has no effect on inference never @@ -260,10 +184,10 @@ type optionalKeyFrom = type PreparsedKey< kind extends ParsedKeyKind = ParsedKeyKind, - inner extends Key = Key + key extends Key = Key > = { kind: kind - key: inner + key: key } namespace PreparsedKey { @@ -276,124 +200,81 @@ export type MetaKey = "..." | "+" export type IndexKey = `[${def}]` -interface PreparsedEntry extends PreparsedKey { - value: unknown - default: unknown -} +export type ParsedEntry = + | ParsedUndeclaredEntry + | ParsedSpreadEntry + | RequiredNode + | OptionalNode + | IndexNode -const unitLiteralKeywords = { - null: null, - undefined, - true: true, - false: false -} as const - -type UnitLiteralKeyword = keyof typeof unitLiteralKeywords - -export type UnitLiteral = - | StringLiteral - | BigintLiteral - | NumberLiteral - | DateLiteral - | UnitLiteralKeyword - -/** Matches a single or double-quoted date or string literal */ -const stringLiteral = anchoredRegex(/(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/) - -/** Matches a definition including a valid default value expression */ -const defaultExpressionMatcher = new RegExp( - `^(?[\\s\\S]*) = (` + - `(?${deanchoredSource(stringLiteral)})` + - `|(?d${deanchoredSource(stringLiteral)})` + - `|(?${deanchoredSource(integerLikeMatcher)}n)` + - `|(?${deanchoredSource(numberLikeMatcher)})` + - `|(?${keysOf(unitLiteralKeywords).join("|")})` + - `)$` -) - -type DefaultExpressionMatcherGroups = { - baseDef: string - string?: StringLiteral - date?: DateLiteral - bigint?: BigintLiteral - number?: NumberLiteral - keyword?: UnitLiteralKeyword +export type ParsedUndeclaredEntry = { + kind: "undeclared" + behavior: UndeclaredKeyBehavior } -type UnitLiteralValue = - | string - | Date - | bigint - | number - | boolean - | null - | undefined - -const parsePossibleDefaultExpression = (s: string) => { - const result = defaultExpressionMatcher.exec(s) - return result && (result.groups as {} as DefaultExpressionMatcherGroups) +export type ParsedSpreadEntry = { + kind: "spread" + node: BaseRoot } -export const parseEntry = (key: Key, value: unknown): PreparsedEntry => { +export const parseEntry = ( + key: Key, + value: unknown, + ctx: ParseContext +): listable => { const parsedKey = parseKey(key) - if (isArray(value) && value[1] === "=") { - if (parsedKey.kind !== "required") - throwParseError(invalidDefaultKeyKindMessage) - return { - kind: "optional", - key: parsedKey.key, - value: value[0], - default: value[2] - } + if (parsedKey.kind === "+") { + if (value !== "reject" && value !== "delete" && value !== "ignore") + throwParseError(writeInvalidUndeclaredBehaviorMessage(value)) + return { kind: "undeclared", behavior: value } } - // if a string includes " = ", it might have a default value, - // but it could also be a string literal like "' = '" - if (typeof value === "string" && value.includes(defaultValueStringOperator)) { - const result = parsePossibleDefaultExpression(value) - if (result) return parseDefaultValueStringExpression(parsedKey, result) - } + if (parsedKey.kind === "...") + return { kind: "spread", node: ctx.$.parse(value, ctx) } - return { - kind: parsedKey.kind, - key: parsedKey.key, - value, - default: unset - } -} + const parsedValue: ParsedDefault | BaseRoot = + isArray(value) && value[1] === "=" ? + [ctx.$.parse(value[0], ctx), "=", value[2]] + : ctx.$.parse(value, ctx, true) -const parseDefaultValueStringExpression = ( - parsedKey: PreparsedKey, - match: DefaultExpressionMatcherGroups -): PreparsedEntry => { - if (parsedKey.kind !== "required") - throwParseError(invalidDefaultKeyKindMessage) + if (isArray(parsedValue)) { + if (parsedKey.kind !== "required") + throwParseError(invalidDefaultKeyKindMessage) - let defaultValue: UnitLiteralValue + const out = parsedValue[0].traverse(parsedValue[2]) + if (out instanceof ArkErrors) { + throwParseError( + writeUnassignableDefaultValueMessage( + printable(parsedKey.key), + out.message + ) + ) + } - if (match.keyword) defaultValue = unitLiteralKeywords[match.keyword] - else if (match.string) defaultValue = match.string.slice(1, -1) - else if (match.number) { - defaultValue = tryParseWellFormedNumber(match.number, { - errorOnFail: true + return ctx.$.node("optional", { + key: parsedKey.key, + value: parsedValue[0], + default: parsedValue[2] }) - } else if (match.date) defaultValue = new Date(match.date) - else if (match.bigint) { - defaultValue = - tryParseWellFormedBigint(match.bigint) ?? - throwInternalError( - `Unexpected default bigint parse result ${match.bigint}` - ) - } else - throwInternalError(`Unexpected default expression parse result ${match}`) + } - return { - kind: "optional", - key: parsedKey.key, - value: match.baseDef, - default: defaultValue + if (parsedKey.kind === "index") { + const signature = ctx.$.parse(parsedKey.key, ctx) + const normalized = normalizeIndex(signature, parsedValue, ctx.$) + return ( + normalized.index ? + normalized.required ? + [normalized.index, ...normalized.required] + : normalized.index + : normalized.required ?? [] + ) } + + return ctx.$.node(parsedKey.kind, { + key: parsedKey.key, + value: parsedValue + }) } // single quote use here is better for TypeScript's inlined error to avoid escapes @@ -404,7 +285,7 @@ export type invalidDefaultKeyKindMessage = typeof invalidDefaultKeyKindMessage const parseKey = (key: Key): PreparsedKey => typeof key === "symbol" ? { kind: "required", key } : key.at(-1) === "?" ? - key.at(-2) === Scanner.escapeToken ? + key.at(-2) === escapeToken ? { kind: "required", key: `${key.slice(0, -2)}?` } : { kind: "optional", @@ -412,9 +293,10 @@ const parseKey = (key: Key): PreparsedKey => } : key[0] === "[" && key.at(-1) === "]" ? { kind: "index", key: key.slice(1, -1) } - : key[0] === Scanner.escapeToken && key[1] === "[" && key.at(-1) === "]" ? + : key[0] === escapeToken && key[1] === "[" && key.at(-1) === "]" ? { kind: "required", key: key.slice(1) } - : key === "..." || key === "+" ? { kind: key, key } + : key === "..." ? { kind: key, key } + : key === "+" ? { kind: key, key } : { kind: "required", key: @@ -425,7 +307,7 @@ const parseKey = (key: Key): PreparsedKey => type parseKey = k extends `${infer inner}?` ? - inner extends `${infer baseName}${Scanner.EscapeToken}` ? + inner extends `${infer baseName}${EscapeToken}` ? PreparsedKey.from<{ kind: "required" key: `${baseName}?` @@ -435,7 +317,7 @@ type parseKey = key: inner }> : k extends MetaKey ? PreparsedKey.from<{ kind: k; key: k }> - : k extends `${Scanner.EscapeToken}${infer escapedMeta extends MetaKey}` ? + : k extends `${EscapeToken}${infer escapedMeta extends MetaKey}` ? PreparsedKey.from<{ kind: "required"; key: escapedMeta }> : k extends IndexKey ? PreparsedKey.from<{ @@ -444,9 +326,7 @@ type parseKey = }> : PreparsedKey.from<{ kind: "required" - key: k extends ( - `${Scanner.EscapeToken}${infer escapedIndexKey extends IndexKey}` - ) ? + key: k extends `${EscapeToken}${infer escapedIndexKey extends IndexKey}` ? escapedIndexKey : k extends Key ? k : `${k & number}` diff --git a/ark/type/parser/semantic/default.ts b/ark/type/parser/semantic/default.ts new file mode 100644 index 0000000000..7ab110a5fd --- /dev/null +++ b/ark/type/parser/semantic/default.ts @@ -0,0 +1,27 @@ +import type { ErrorMessage } from "@arktype/util" +import type { UnitLiteral } from "../string/shift/operator/default.js" +import type { inferAstOut, inferTerminal } from "./infer.js" +import type { astToString } from "./utils.js" +import type { validateAst } from "./validate.js" + +export type validateDefault = + validateAst extends infer e extends ErrorMessage ? e + : // check against the output of the type since morphs will not occur + inferTerminal extends inferAstOut ? + undefined + : ErrorMessage< + writeUnassignableDefaultValueMessage, unitLiteral> + > + +export const writeUnassignableDefaultValueMessage = < + key extends string, + message extends string +>( + key: key, + message: message +) => `Default value at ${key} ${message}` + +export type writeUnassignableDefaultValueMessage< + baseDef extends string, + defaultValue extends string +> = `Default value ${defaultValue} is not assignable to ${baseDef}` diff --git a/ark/type/parser/semantic/infer.ts b/ark/type/parser/semantic/infer.ts index c3cbe998ba..54c9f31900 100644 --- a/ark/type/parser/semantic/infer.ts +++ b/ark/type/parser/semantic/infer.ts @@ -1,11 +1,13 @@ import type { Date, DateLiteral, + Default, GenericProps, LimitLiteral, RegexLiteral, constrain, distillIn, + distillOut, inferIntersection, normalizeLimit, string @@ -24,6 +26,8 @@ export type inferAstRoot = inferConstrainableAst export type inferAstIn = distillIn> +export type inferAstOut = distillOut> + export type inferConstrainableAst = ast extends array ? inferExpression : ast extends string ? inferTerminal @@ -64,6 +68,10 @@ export type inferExpression = inferConstrainableAst, inferConstrainableAst > + : ast[1] extends "=" ? + inferTerminal extends infer defaultValue ? + (In?: inferConstrainableAst) => Default + : never : ast[1] extends Comparator ? ast[0] extends LimitLiteral ? constrainBound, ast[1], ast[0]> diff --git a/ark/type/parser/semantic/validate.ts b/ark/type/parser/semantic/validate.ts index 511881fc57..61d2b37d9e 100644 --- a/ark/type/parser/semantic/validate.ts +++ b/ark/type/parser/semantic/validate.ts @@ -14,8 +14,10 @@ import type { } from "@arktype/util" import type { Comparator } from "../string/reduce/shared.js" import type { writeInvalidGenericArgsMessage } from "../string/shift/operand/genericArgs.js" +import type { UnitLiteral } from "../string/shift/operator/default.js" import type { parseString } from "../string/string.js" import type { validateRange } from "./bounds.js" +import type { validateDefault } from "./default.js" import type { validateDivisor } from "./divisor.js" import type { GenericInstantiationAst, @@ -35,6 +37,10 @@ export type validateAst = : operator extends Comparator ? validateRange : operator extends "%" ? validateDivisor : undefined + : ast extends ( + readonly [infer baseAst, "=", infer unitLiteral extends UnitLiteral] + ) ? + validateDefault : ast extends readonly ["keyof", infer operand] ? validateAst : ast extends GenericInstantiationAst ? validateGenericArgs diff --git a/ark/type/parser/string/reduce/dynamic.ts b/ark/type/parser/string/reduce/dynamic.ts index 0db0e573ea..18e9380129 100644 --- a/ark/type/parser/string/reduce/dynamic.ts +++ b/ark/type/parser/string/reduce/dynamic.ts @@ -49,7 +49,8 @@ export class DynamicState { constructor( def: string, - public readonly ctx: ParseContext + public readonly ctx: ParseContext, + public readonly defaultable: boolean ) { this.scanner = new Scanner(def) } @@ -154,7 +155,7 @@ export class DynamicState { parseUntilFinalizer(): DynamicStateWithRoot { return parseUntilFinalizer( - new DynamicState(this.scanner.unscanned, this.ctx) + new DynamicState(this.scanner.unscanned, this.ctx, false) ) } diff --git a/ark/type/parser/string/shift/operand/operand.ts b/ark/type/parser/string/shift/operand/operand.ts index 97b076ca12..dc3987cde3 100644 --- a/ark/type/parser/string/shift/operand/operand.ts +++ b/ark/type/parser/string/shift/operand/operand.ts @@ -1,7 +1,8 @@ +import { whiteSpaceTokens, type WhiteSpaceToken } from "@arktype/util" import type { DynamicState } from "../../reduce/dynamic.js" import type { StaticState, state } from "../../reduce/static.js" import type { BaseCompletions } from "../../string.js" -import { Scanner } from "../scanner.js" +import type { Scanner } from "../scanner.js" import { enclosingChar, enclosingQuote, @@ -15,8 +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(Scanner.whiteSpaceTokens) ? - parseOperand(s.shiftedByOne()) + : s.scanner.lookaheadIsIn(whiteSpaceTokens) ? parseOperand(s.shiftedByOne()) : s.scanner.lookahead === "d" ? s.scanner.nextLookahead in enclosingQuote ? parseEnclosed( @@ -31,7 +31,7 @@ export type parseOperand = lookahead extends "(" ? state.reduceGroupOpen : lookahead extends EnclosingStartToken ? parseEnclosed - : lookahead extends Scanner.WhiteSpaceToken ? + : lookahead extends WhiteSpaceToken ? parseOperand, $, args> : lookahead extends "d" ? unscanned extends ( diff --git a/ark/type/parser/string/shift/operator/bounds.ts b/ark/type/parser/string/shift/operator/bounds.ts index 1780d6f29a..4efe9844d7 100644 --- a/ark/type/parser/string/shift/operator/bounds.ts +++ b/ark/type/parser/string/shift/operator/bounds.ts @@ -92,17 +92,16 @@ const shiftComparator = ( s: DynamicState, start: ComparatorStartChar ): Comparator => - s.scanner.lookaheadIs("=") ? `${start}${s.scanner.shift()}` - : isKeyOf(start, oneCharComparators) ? start - : s.error(singleEqualsMessage) + s.scanner.lookaheadIs("=") ? + `${start}${s.scanner.shift()}` + : (start as OneCharComparator) type shiftComparator< start extends ComparatorStartChar, unscanned extends string > = unscanned extends `=${infer nextUnscanned}` ? [`${start}=`, nextUnscanned] - : start extends OneCharComparator ? [start, unscanned] - : state.error + : [start & OneCharComparator, unscanned] export const writeIncompatibleRangeMessage = ( l: BoundKind, @@ -150,9 +149,6 @@ export const getBoundKinds = ( return throwParseError(writeUnboundableMessage(root.expression)) } -export const singleEqualsMessage = `= is not valid here. Default values must be specified on objects like { isAdmin: 'boolean = false' }` -type singleEqualsMessage = typeof singleEqualsMessage - const openLeftBoundToRoot = ( leftBound: OpenLeftBound ): NodeSchema => ({ diff --git a/ark/type/parser/string/shift/operator/default.ts b/ark/type/parser/string/shift/operator/default.ts new file mode 100644 index 0000000000..f44d784324 --- /dev/null +++ b/ark/type/parser/string/shift/operator/default.ts @@ -0,0 +1,64 @@ +import type { BaseRoot, DateLiteral } from "@arktype/schema" +import { + throwParseError, + type BigintLiteral, + type ErrorMessage, + type NumberLiteral, + type trim +} from "@arktype/util" +import type { DynamicStateWithRoot } from "../../reduce/dynamic.js" +import type { StringLiteral } from "../operand/enclosed.js" + +const unitLiteralKeywords = { + null: null, + undefined, + true: true, + false: false +} as const + +type UnitLiteralKeyword = keyof typeof unitLiteralKeywords + +export type UnitLiteral = + | StringLiteral + | BigintLiteral + | NumberLiteral + | DateLiteral + | UnitLiteralKeyword + +export type ParsedDefault = [BaseRoot, "=", unknown] + +export const parseDefault = (s: DynamicStateWithRoot): ParsedDefault => { + if (!s.defaultable) return throwParseError(shallowDefaultMessage) + + // store the node that will be bounded + const baseNode = s.unsetRoot() + s.parseOperand() + const defaultNode = s.unsetRoot() + // after parsing the next operand, use the locations to get the + // token from which it was parsed + if (!defaultNode.hasKind("unit")) + return s.error(writeNonLiteralDefaultMessage(defaultNode.expression)) + + // assignability is checked in parseEntries + + return [baseNode, "=", defaultNode.unit] +} + +export type parseDefault = + // default values must always appear at the end of a string definition, + // so parse the rest of the string and ensure it is a valid unit literal + trim extends infer defaultValue extends UnitLiteral ? + [root, "=", defaultValue] + : ErrorMessage>> + +export const writeNonLiteralDefaultMessage = ( + defaultDef: defaultDef +): writeNonLiteralDefaultMessage => + `Default value '${defaultDef}' must a literal value` + +export type writeNonLiteralDefaultMessage = + `Default value '${defaultDef}' must a literal value` + +export const shallowDefaultMessage = `Default values must be specified on objects like { isAdmin: 'boolean = false' }` + +export type shallowDefaultMessage = typeof shallowDefaultMessage diff --git a/ark/type/parser/string/shift/operator/operator.ts b/ark/type/parser/string/shift/operator/operator.ts index 7ce457b9b6..d56cd49951 100644 --- a/ark/type/parser/string/shift/operator/operator.ts +++ b/ark/type/parser/string/shift/operator/operator.ts @@ -1,4 +1,4 @@ -import { isKeyOf } from "@arktype/util" +import { isKeyOf, type WhiteSpaceToken } from "@arktype/util" import type { DynamicStateWithRoot } from "../../reduce/dynamic.js" import type { StaticState, state } from "../../reduce/static.js" import { Scanner } from "../scanner.js" @@ -44,7 +44,7 @@ export type parseOperator = : lookahead extends ComparatorStartChar ? parseBound : lookahead extends "%" ? parseDivisor - : lookahead extends Scanner.WhiteSpaceToken ? + : lookahead extends WhiteSpaceToken ? parseOperator, $, args> : state.error> : state.finalize diff --git a/ark/type/parser/string/shift/scanner.ts b/ark/type/parser/string/shift/scanner.ts index c9ead3e15d..2ce669317e 100644 --- a/ark/type/parser/string/shift/scanner.ts +++ b/ark/type/parser/string/shift/scanner.ts @@ -1,4 +1,11 @@ -import { isKeyOf, type Dict } from "@arktype/util" +import { + escapeToken, + isKeyOf, + whiteSpaceTokens, + type Dict, + type EscapeToken, + type WhiteSpaceToken +} from "@arktype/util" import type { Comparator } from "../reduce/shared.js" export class Scanner { @@ -31,7 +38,7 @@ export class Scanner { let shifted = "" while (this.lookahead) { if (condition(this, shifted)) { - if (shifted[shifted.length - 1] === Scanner.escapeToken) + if (shifted[shifted.length - 1] === escapeToken) shifted = shifted.slice(0, -1) else break } @@ -113,7 +120,8 @@ export namespace Scanner { export const finalizingLookaheads = { ">": true, ",": true, - "": true + "": true, + "=": true } as const export type FinalizingLookahead = keyof typeof finalizingLookaheads @@ -124,22 +132,10 @@ export namespace Scanner { export type OperatorToken = InfixToken | PostfixToken - export const escapeToken = "\\" - - export type EscapeToken = typeof escapeToken - - export const whiteSpaceTokens = { - " ": true, - "\n": true, - "\t": true - } as const - - export type WhiteSpaceToken = keyof typeof whiteSpaceTokens - export const lookaheadIsFinalizing = ( lookahead: string, unscanned: string - ): lookahead is ">" | "," => + ): lookahead is ">" | "," | "=" => lookahead === ">" ? unscanned[0] === "=" ? // >== would only occur in an expression like Array==5 @@ -148,8 +144,11 @@ export namespace Scanner { // 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) - // if the lookahead is a finalizing token but not >, it's unambiguously a finalizer (currently just ",") - : lookahead === "," + // "=" is a finalizer on its own (representing a default value), + // but not with a second "=" (an equality comparator) + : lookahead === "=" ? unscanned[0] !== "=" + // ","" is unambiguously a finalizer + : lookahead === "," export type lookaheadIsFinalizing< lookahead extends string, @@ -165,6 +164,10 @@ export namespace Scanner { ) ? true : false + : lookahead extends "=" ? + unscanned extends `=${string}` ? + false + : true : lookahead extends "," ? true : false diff --git a/ark/type/parser/string/string.ts b/ark/type/parser/string/string.ts index 7994997e04..7bb0247336 100644 --- a/ark/type/parser/string/string.ts +++ b/ark/type/parser/string/string.ts @@ -7,8 +7,9 @@ import { import type { inferAstRoot } from "../semantic/infer.js" import type { DynamicState, DynamicStateWithRoot } from "./reduce/dynamic.js" import type { StringifiablePrefixOperator } from "./reduce/shared.js" -import type { StaticState, state } from "./reduce/static.js" +import type { state, StaticState } from "./reduce/static.js" import type { parseOperand } from "./shift/operand/operand.js" +import { parseDefault, type ParsedDefault } from "./shift/operator/default.js" import { writeUnexpectedCharacterMessage, type parseOperator @@ -42,14 +43,18 @@ export type BaseCompletions<$, args, otherSuggestions extends string = never> = | StringifiablePrefixOperator | otherSuggestions -export const fullStringParse = (s: DynamicState): BaseRoot => { +export type StringParseResult = BaseRoot | ParsedDefault + +export const fullStringParse = (s: DynamicState): StringParseResult => { s.parseOperand() - const result = parseUntilFinalizer(s).root + let result: StringParseResult = parseUntilFinalizer(s).root if (!result) { return throwInternalError( `Root was unexpectedly unset after parsing string '${s.scanner.scanned}'` ) } + if (s.finalizer === "=") result = parseDefault(s as DynamicStateWithRoot) + s.scanner.shiftUntilNonWhitespace() if (s.scanner.lookahead) { // throw a parse error if non-whitespace characters made it here without being parsed @@ -81,6 +86,7 @@ type next = : parseOperator export type extractFinalizedResult = - s["finalizer"] extends ErrorMessage ? s["finalizer"] - : s["finalizer"] extends "" ? s["root"] + s["finalizer"] extends "" ? s["root"] + : s["finalizer"] extends ErrorMessage ? s["finalizer"] + : s["finalizer"] extends "=" ? parseDefault : state.error> diff --git a/ark/type/parser/tuple.ts b/ark/type/parser/tuple.ts index ba5cbac429..d53c398fdd 100644 --- a/ark/type/parser/tuple.ts +++ b/ark/type/parser/tuple.ts @@ -10,7 +10,6 @@ import { type Out, type Predicate, type UnionChildKind, - type UnknownRoot, type distillConstrainableIn, type distillOut, type inferIntersection, @@ -93,7 +92,7 @@ type ElementKind = "optional" | "required" | "variadic" const appendElement = ( base: MutableInner<"sequence">, kind: ElementKind, - element: UnknownRoot + element: BaseRoot ): MutableInner<"sequence"> => { switch (kind) { case "required": @@ -140,7 +139,7 @@ const appendSpreadBranch = ( const spread = branch.firstReferenceOfKind("sequence") if (!spread) { // the only array with no sequence reference is unknown[] - return appendElement(base, "variadic", tsKeywords.unknown) + return appendElement(base, "variadic", tsKeywords.unknown.internal) } spread.prefix.forEach(node => appendElement(base, "required", node)) spread.optionals.forEach(node => appendElement(base, "optional", node)) diff --git a/ark/type/scope.ts b/ark/type/scope.ts index 133a07f465..ef0ae3cdce 100644 --- a/ark/type/scope.ts +++ b/ark/type/scope.ts @@ -42,7 +42,11 @@ import { type GenericParamsParseError } from "./parser/generic.js" import { DynamicState } from "./parser/string/reduce/dynamic.js" -import { fullStringParse } from "./parser/string/string.js" +import type { ParsedDefault } from "./parser/string/shift/operator/default.js" +import { + fullStringParse, + type StringParseResult +} from "./parser/string/string.js" import { RawTypeParser, type DeclarationParser, @@ -188,7 +192,7 @@ export interface Scope<$ = any> extends RootScope<$> { export class RawScope< $ extends RawRootResolutions = RawRootResolutions > extends RawRootScope<$> { - private parseCache: Record = {} + private parseCache: Record = {} constructor(def: Record, config?: ArkConfig) { const aliases: Record = {} @@ -228,30 +232,44 @@ export class RawScope< }).bindScope(this) } - parse(def: unknown, ctx: ParseContext): BaseRoot { + parse( + def: unknown, + ctx: ParseContext, + defaultable: defaultable = false as defaultable + ): BaseRoot | (defaultable extends false ? never : ParsedDefault) { if (typeof def === "string") { if (ctx.args && Object.keys(ctx.args).every(k => !def.includes(k))) { // we can only rely on the cache if there are no contextual // resolutions like "this" or generic args - return this.parseString(def, ctx) + return this.parseString(def, ctx, defaultable) } - if (!this.parseCache[def]) - this.parseCache[def] = this.parseString(def, ctx) + const contextKey = `${def}${defaultable}` + if (!this.parseCache[contextKey]) + this.parseCache[contextKey] = this.parseString(def, ctx, defaultable) - return this.parseCache[def] + return this.parseCache[contextKey] as never } return hasDomain(def, "object") ? parseObject(def, ctx) : throwParseError(writeBadDefinitionTypeMessage(domainOf(def))) } - parseString(def: string, ctx: ParseContext): BaseRoot { - return ( - this.maybeResolveRoot(def) ?? - ((def.endsWith("[]") && - this.maybeResolveRoot(def.slice(0, -2))?.array()) || - fullStringParse(new DynamicState(def, ctx))) - ) + parseString( + def: string, + ctx: ParseContext, + defaultable: defaultable + ): BaseRoot | (defaultable extends false ? never : ParsedDefault) { + const aliasResolution = this.maybeResolveRoot(def) + if (aliasResolution) return aliasResolution + + const aliasArrayResolution = + def.endsWith("[]") ? + this.maybeResolveRoot(def.slice(0, -2))?.array() + : undefined + + if (aliasArrayResolution) return aliasArrayResolution + + return fullStringParse(new DynamicState(def, ctx, defaultable)) as never } } diff --git a/ark/util/arrays.ts b/ark/util/arrays.ts index b3bad9473e..dfbab61b65 100644 --- a/ark/util/arrays.ts +++ b/ark/util/arrays.ts @@ -143,21 +143,18 @@ export type AppendOptions = { * * @param to The array to which `value` is to be added. If `to` is `undefined`, a new array * is created as `[value]` if value was not undefined, otherwise `[]`. - * @param value The value to add to the array. If `value` is `undefined`, does nothing. + * @param value The value to add to the array. * @param opts * prepend: If true, adds the element to the beginning of the array instead of the end */ export const append = < - to extends element[] | undefined, - element, - value extends listable | undefined + to extends unknown[] | undefined, + value extends listable<(to & {})[number]> >( to: to, value: value, opts?: AppendOptions -): Exclude | Extract => { - if (value === undefined) return to ?? ([] as any) - +): to & {} => { if (to === undefined) { return ( value === undefined ? [] diff --git a/ark/util/strings.ts b/ark/util/strings.ts index 003e574e76..7d2a164108 100644 --- a/ark/util/strings.ts +++ b/ark/util/strings.ts @@ -50,6 +50,26 @@ export const deanchoredSource = (regex: RegExp | string) => { ) } +export const escapeToken = "\\" + +export type EscapeToken = typeof escapeToken + +export const whiteSpaceTokens = { + " ": true, + "\n": true, + "\t": true +} as const + +export type WhiteSpaceToken = keyof typeof whiteSpaceTokens + +export type trim = trimEnd> + +export type trimStart = + s extends `${WhiteSpaceToken}${infer tail}` ? trimEnd : s + +export type trimEnd = + s extends `${infer init}${WhiteSpaceToken}` ? trimEnd : s + // Credit to @gugaguichard for this! https://x.com/gugaguichard/status/1720528864500150534 export type isStringLiteral = [t] extends [string] ? diff --git a/package.json b/package.json index fa6bcc8794..4a6f33c24f 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "knip": "5.22.0", "mocha": "10.4.0", "prettier": "3.3.2", - "tsx": "4.15.6", + "tsx": "4.15.7", "typescript": "5.5.2", "typescript-min": "npm:typescript@5.1.5", "typescript-nightly": "npm:typescript@next"