diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e552f3141..e1bf8c926e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,8 +18,8 @@ // too many overlapping names, easy to import in schema/arktype where we don't want it // should just import as * as ts when we need it in attest "typescript", - "./ark/type/api.ts", - "./ark/schema/api.ts" + "./ark/type/api.ts" + // "./ark/schema/api.ts" ], "typescript.tsserver.experimental.enableProjectDiagnostics": true, // IF YOU UPDATE THE MOCHA CONFIG HERE, PLEASE ALSO UPDATE package.json/mocha AND ark/repo/mocha.jsonc diff --git a/ark/attest/__tests__/benchExpectedOutput.ts b/ark/attest/__tests__/benchExpectedOutput.ts index db78710ffc..40438d5398 100644 --- a/ark/attest/__tests__/benchExpectedOutput.ts +++ b/ark/attest/__tests__/benchExpectedOutput.ts @@ -9,25 +9,19 @@ const fakeCallOptions = { bench( "bench call single stat median", - () => { - return "boofoozoo".includes("foo") - }, + () => "boofoozoo".includes("foo"), fakeCallOptions ).median([2, "ms"]) bench( "bench call single stat", - () => { - return "boofoozoo".includes("foo") - }, + () => "boofoozoo".includes("foo"), fakeCallOptions ).mean([2, "ms"]) bench( "bench call mark", - () => { - return /.*foo.*/.test("boofoozoo") - }, + () => /.*foo.*/.test("boofoozoo"), fakeCallOptions ).mark({ mean: [2, "ms"], median: [2, "ms"] }) @@ -35,19 +29,19 @@ type makeComplexType<S extends string> = S extends `${infer head}${infer tail}` ? head | tail | makeComplexType<tail> : S -bench("bench type", () => { - return {} as makeComplexType<"defenestration"> -}).types([176, "instantiations"]) +bench("bench type", () => ({}) as makeComplexType<"defenestration">).types([ + 176, + "instantiations" +]) -bench("bench type from external module", () => { - return {} as externalmakeComplexType<"defenestration"> -}).types([193, "instantiations"]) +bench( + "bench type from external module", + () => ({}) as externalmakeComplexType<"defenestration"> +).types([193, "instantiations"]) bench( "bench call and type", - () => { - return {} as makeComplexType<"antidisestablishmentarianism"> - }, + () => ({}) as makeComplexType<"antidisestablishmentarianism">, fakeCallOptions ) .mean([2, "ms"]) diff --git a/ark/attest/__tests__/benchTemplate.ts b/ark/attest/__tests__/benchTemplate.ts index 077d49a7ff..e5764f511a 100644 --- a/ark/attest/__tests__/benchTemplate.ts +++ b/ark/attest/__tests__/benchTemplate.ts @@ -9,25 +9,19 @@ const fakeCallOptions = { bench( "bench call single stat median", - () => { - return "boofoozoo".includes("foo") - }, + () => "boofoozoo".includes("foo"), fakeCallOptions ).median() bench( "bench call single stat", - () => { - return "boofoozoo".includes("foo") - }, + () => "boofoozoo".includes("foo"), fakeCallOptions ).mean() bench( "bench call mark", - () => { - return /.*foo.*/.test("boofoozoo") - }, + () => /.*foo.*/.test("boofoozoo"), fakeCallOptions ).mark() @@ -35,19 +29,16 @@ type makeComplexType<S extends string> = S extends `${infer head}${infer tail}` ? head | tail | makeComplexType<tail> : S -bench("bench type", () => { - return {} as makeComplexType<"defenestration"> -}).types() +bench("bench type", () => ({}) as makeComplexType<"defenestration">).types() -bench("bench type from external module", () => { - return {} as externalmakeComplexType<"defenestration"> -}).types() +bench( + "bench type from external module", + () => ({}) as externalmakeComplexType<"defenestration"> +).types() bench( "bench call and type", - () => { - return {} as makeComplexType<"antidisestablishmentarianism"> - }, + () => ({}) as makeComplexType<"antidisestablishmentarianism">, fakeCallOptions ) .mean() diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts index 28b9c131f9..b994c2d2f2 100644 --- a/ark/attest/bench/bench.ts +++ b/ark/attest/bench/bench.ts @@ -333,8 +333,8 @@ export const getBenchCtx = ( qualifiedPath: string[], isAsync: boolean = false, options: BenchOptions = {} -): BenchContext => { - return { +): BenchContext => + ({ qualifiedPath, qualifiedName: qualifiedPath.join("/"), options, @@ -342,5 +342,4 @@ export const getBenchCtx = ( benchCallPosition: caller(), lastSnapCallPosition: undefined, isAsync - } as BenchContext -} + }) as BenchContext diff --git a/ark/attest/bench/measure.ts b/ark/attest/bench/measure.ts index 33e671437e..eb3886b195 100644 --- a/ark/attest/bench/measure.ts +++ b/ark/attest/bench/measure.ts @@ -26,12 +26,10 @@ export type TypeUnit = (typeof TYPE_UNITS)[number] export const createTypeComparison = ( value: number, baseline: Measure<TypeUnit> | undefined -): MeasureComparison<TypeUnit> => { - return { - updated: [value, "instantiations"], - baseline - } -} +): MeasureComparison<TypeUnit> => ({ + updated: [value, "instantiations"], + baseline +}) export const timeUnitRatios = { ns: 0.000_001, diff --git a/ark/attest/cache/getCachedAssertions.ts b/ark/attest/cache/getCachedAssertions.ts index e8054c3cf9..dd823d64de 100644 --- a/ark/attest/cache/getCachedAssertions.ts +++ b/ark/attest/cache/getCachedAssertions.ts @@ -88,7 +88,7 @@ const getAssertionsOfKindAtPosition = <kind extends TypeAssertionKind>( `Found no assertion data for '${fileKey}' for TypeScript version ${version}.` ) } - const matchingAssertion = assertions[fileKey].find(assertion => { + const matchingAssertion = assertions[fileKey].find(assertion => /** * Depending on the environment, a trace can refer to any of these points * attest(...) @@ -96,8 +96,8 @@ const getAssertionsOfKindAtPosition = <kind extends TypeAssertionKind>( * Because of this, it's safest to check if the call came from anywhere in the expected range. * */ - return isPositionWithinRange(position, assertion.location) - }) + isPositionWithinRange(position, assertion.location) + ) if (!matchingAssertion) { throw new Error( `Found no assertion for TypeScript version ${version} at line ${position.line} char ${position.char} in '${fileKey}'. diff --git a/ark/attest/cache/ts.ts b/ark/attest/cache/ts.ts index e243eaa012..2de2b8c6d0 100644 --- a/ark/attest/cache/ts.ts +++ b/ark/attest/cache/ts.ts @@ -20,8 +20,11 @@ export class TsServer { const tsLibPaths = getTsLibFiles(tsConfigInfo.parsed.options) + // TS represents windows paths as `C:/Users/ssalb/...` + const normalizedCwd = fromCwd().replaceAll(/\\/g, "/") + this.rootFiles = tsConfigInfo.parsed.fileNames.filter(path => - path.startsWith(fromCwd()) + path.startsWith(normalizedCwd) ) const system = tsvfs.createFSBackedSystem( @@ -41,7 +44,8 @@ export class TsServer { } getSourceFileOrThrow(path: string): ts.SourceFile { - const file = this.virtualEnv.getSourceFile(path) + const tsPath = path.replaceAll(/\\/g, "/") + const file = this.virtualEnv.getSourceFile(tsPath) if (!file) throw new Error(`Could not find ${path}.`) return file diff --git a/ark/attest/config.ts b/ark/attest/config.ts index 1d0b971ade..6164d484be 100644 --- a/ark/attest/config.ts +++ b/ark/attest/config.ts @@ -44,25 +44,21 @@ type BaseAttestConfig = { export type AttestConfig = Partial<BaseAttestConfig> -export const getDefaultAttestConfig = (): BaseAttestConfig => { - return { - tsconfig: - existsSync(fromCwd("tsconfig.json")) ? - fromCwd("tsconfig.json") - : undefined, - attestAliases: ["attest", "attestInternal"], - updateSnapshots: false, - skipTypes: false, - skipInlineInstantiations: false, - tsVersions: "typescript", - benchPercentThreshold: 20, - benchErrorOnThresholdExceeded: false, - filter: undefined, - testDeclarationAliases: ["bench", "it"], - formatter: `npm exec --no -- prettier --write`, - shouldFormat: true - } -} +export const getDefaultAttestConfig = (): BaseAttestConfig => ({ + tsconfig: + existsSync(fromCwd("tsconfig.json")) ? fromCwd("tsconfig.json") : undefined, + attestAliases: ["attest", "attestInternal"], + updateSnapshots: false, + skipTypes: false, + skipInlineInstantiations: false, + tsVersions: "typescript", + benchPercentThreshold: 20, + benchErrorOnThresholdExceeded: false, + filter: undefined, + testDeclarationAliases: ["bench", "it"], + formatter: `npm exec --no -- prettier --write`, + shouldFormat: true +}) const hasFlag = (flag: keyof AttestConfig) => process.argv.some(arg => arg.includes(flag)) diff --git a/ark/attest/package.json b/ark/attest/package.json index 785a66f7f3..b99dee989d 100644 --- a/ark/attest/package.json +++ b/ark/attest/package.json @@ -17,7 +17,7 @@ "out" ], "bin": { - "attest": "./out/cli/cli.js" + "attest": "out/cli/cli.js" }, "scripts": { "build": "tsx ../repo/build.ts", diff --git a/ark/dark/color-theme.json b/ark/dark/color-theme.json index 7358a6c8fc..cc46e2c3f7 100644 --- a/ark/dark/color-theme.json +++ b/ark/dark/color-theme.json @@ -6,49 +6,50 @@ "name": "functions", "scope": [ "entity.name.function", - "meta.function-call", "support.function", + "meta.function-call.python", // We have this for Python in honor of contributor TizzySaurus. // Will be removed for consistency once they allow it or the next time someone complains about it. "meta.function.decorator punctuation", "meta.function.decorator support.type" ], "settings": { - "foreground": "#80cff8" + "foreground": "#80cff8", + "fontStyle": "italic" } }, { - "name": "types", - "scope": ["entity.name.type"], + "name": "types and decorators", + "scope": ["entity.name.type", "meta.decorator.ts variable"], "settings": { "foreground": "#40decc" } }, { - "name": "keywords and operators", - "scope": [ - "keyword", - "storage", - "punctuation", - "constant.character.escape" - ], + "scope": ["meta.type.declaration.ts entity.name.type"], "settings": { - "foreground": "#eb9f2e" + "foreground": "#80cff8", + "fontStyle": "italic" } }, { - "name": "italics", - // italicize keywords that are not punctuation/symbols - "scope": ["keyword", "storage", "keyword.operator.expression"], + "scope": [ + "meta.type.declaration.ts meta.type.parameters.ts entity.name.type" + ], "settings": { - "fontStyle": "italic" + "fontStyle": "" } }, { - "name": "unitalicized", - "scope": ["keyword.operator", "storage.type.function.arrow.ts"], + "name": "keywords and operators", + "scope": [ + "keyword", + "storage", + "punctuation", + "constant.character.escape" + ], "settings": { - "fontStyle": "" + "foreground": "#ba7e41" } }, { @@ -116,9 +117,9 @@ "list.errorForeground": "#9558f8", "editorOverviewRuler.errorForeground": "#9558f8", "editorBracketHighlight.foreground1": "#f5cf8f", - "editorBracketHighlight.foreground2": "#eb9f2e", - "editorBracketHighlight.foreground3": "#f5cf8f", - "editorBracketHighlight.unexpectedBracket.foreground": "#eb9f2e", + "editorBracketHighlight.foreground2": "#ba7e41", + "editorBracketHighlight.foreground3": "#eb9f2e", + "editorBracketHighlight.unexpectedBracket.foreground": "#ba7e41", "editorCursor.foreground": "#408fde", "terminalCursor.foreground": "#408fde", "editorCodeLens.foreground": "#00ccff60", diff --git a/ark/dark/injected.tmLanguage.json b/ark/dark/injected.tmLanguage.json index 97ebb80120..3b2b7555c9 100644 --- a/ark/dark/injected.tmLanguage.json +++ b/ark/dark/injected.tmLanguage.json @@ -10,7 +10,7 @@ "repository": { "arkDefinition": { "contentName": "meta.embedded.arktype.definition", - "begin": "(([^\\)\\(\\s]*)?((\\.)?(type|scope|define|match|attest)|(\\.)(morph|and|or|when)))\\(", + "begin": "(([^\\)\\(\\s]*)?((\\.)?(type|scope|define|match|fn)|(\\.)(morph|and|or|when)))\\(", "beginCaptures": { "1": { "name": "entity.name.function.ts" diff --git a/ark/dark/package.json b/ark/dark/package.json index 232bfc6aad..beee40f555 100644 --- a/ark/dark/package.json +++ b/ark/dark/package.json @@ -2,7 +2,7 @@ "name": "arkdark", "displayName": "ArkDark", "description": "ArkType syntax highlighting and themeāµ", - "version": "5.0.2", + "version": "5.1.3", "publisher": "arktypeio", "type": "module", "scripts": { diff --git a/ark/dark/theme.png b/ark/dark/theme.png new file mode 100644 index 0000000000..25ef05c7f3 Binary files /dev/null and b/ark/dark/theme.png differ diff --git a/ark/dark/tsWithArkType.tmLanguage.json b/ark/dark/tsWithArkType.tmLanguage.json index 6f26de8be7..42de9f493f 100644 --- a/ark/dark/tsWithArkType.tmLanguage.json +++ b/ark/dark/tsWithArkType.tmLanguage.json @@ -22,7 +22,7 @@ "repository": { "arkDefinition": { "contentName": "meta.embedded.arktype.definition", - "begin": "(([^\\)\\(\\s]*)?((\\.)?(type|scope|define|match|attest)|(\\.)(morph|and|or|when)))\\(", + "begin": "(([^\\)\\(\\s]*)?((\\.)?(type|scope|define|match|fn)|(\\.)(morph|and|or|when)))\\(", "beginCaptures": { "1": { "name": "entity.name.function.ts" diff --git a/ark/docs/package.json b/ark/docs/package.json index 6d8e783488..daccf44540 100644 --- a/ark/docs/package.json +++ b/ark/docs/package.json @@ -12,22 +12,21 @@ }, "dependencies": { "@arktype/util": "workspace:*", - "@astrojs/starlight": "0.21.1", - "@astrojs/react": "3.0.10", + "@astrojs/starlight": "0.23.0", + "@astrojs/react": "3.3.4", "@monaco-editor/react": "4.6.0", - "monaco-editor": "0.47.0", + "monaco-editor": "0.48.0", "monaco-textmate": "3.0.1", "monaco-editor-textmate": "4.0.0", "onigasm": "2.2.5", - "@stackblitz/sdk": "1.9.0", - "astro": "4.4.15", - "sharp": "0.33.2", - "react": "18.2.0", - "react-dom": "18.2.0", - "framer-motion": "11.0.8" + "astro": "4.8.6", + "sharp": "0.33.4", + "react": "18.3.1", + "react-dom": "18.3.1", + "framer-motion": "11.2.4" }, "devDependencies": { - "@types/react": "18.2.64", - "@types/react-dom": "18.2.21" + "@types/react": "18.3.2", + "@types/react-dom": "18.3.0" } } diff --git a/ark/docs/src/assets/splash.png b/ark/docs/src/assets/splash.png new file mode 100644 index 0000000000..7d9ad065f4 Binary files /dev/null and b/ark/docs/src/assets/splash.png differ diff --git a/ark/docs/src/components/HomeDemo.tsx b/ark/docs/src/components/HomeDemo.tsx index cc7d79e382..3086a25174 100644 --- a/ark/docs/src/components/HomeDemo.tsx +++ b/ark/docs/src/components/HomeDemo.tsx @@ -35,14 +35,15 @@ const translateVSCodeTheme = ( colors: theme.colors, rules: theme.tokenColors.flatMap(c => { if (Array.isArray(c.scope)) { - return c.scope.map(sub => { - return { - token: sub, - background: c.settings.background, - foreground: c.settings.foreground, - fontStyle: c.settings.fontStyle - } as Monaco.editor.ITokenThemeRule - }) + return c.scope.map( + sub => + ({ + token: sub, + background: c.settings.background, + foreground: c.settings.foreground, + fontStyle: c.settings.fontStyle + }) as Monaco.editor.ITokenThemeRule + ) } return { token: c.scope, diff --git a/ark/docs/src/content/docs/index.mdx b/ark/docs/src/content/docs/index.mdx index 1f8946f9d7..e58748d2d6 100644 --- a/ark/docs/src/content/docs/index.mdx +++ b/ark/docs/src/content/docs/index.mdx @@ -3,15 +3,19 @@ title: ArkType description: Documentation for ArkType, an open-source runtime validation library for TypeScript template: splash hero: - tagline: TypeScript's 1:1 validator, optimized from editor to runtime + title: ArkType + tagline: TypeScript's 1:1 validator, optimized from editor to runtime. + image: + alt: A serene ark, sailing to runtime + file: ../../assets/splash.png actions: - - text: Meet 2.0 + - text: Set sail link: /intro/why/ icon: right-arrow variant: primary - # - text: Read more - # link: https://starlight.astro.build - # icon: external + - text: Doc up + link: https://github.com/arktypeio/arktype + icon: external --- import { Card, CardGrid } from "@astrojs/starlight/components" @@ -19,31 +23,25 @@ import { HeroContents } from "../../components/HeroContents.tsx" import { HomeDemo } from "../../components/HomeDemo.tsx" <div class="not-content"> - <HeroContents client:only /> + <HeroContents client:only="react" /> </div> -## v2 is coming - -Thank you for all the wonderful feedback on our v1 release. Basic docs for that can be found [here](). - -However, most of our effort has been diverted to shipping an ambitious v2 release that will establish a stable, well-documented foundation for ArkType not just as a best-in-class validator, but as the first true runtime type system for JavaScript. - -We coudn't be more excited to share it with you. - -{/* prettier-ignore */} -{/* ## Highlights +## What awaits <CardGrid stagger> - <Card title="Update content" icon="pencil"> - Edit `src/content/docs/index.mdx` to see this page change. + <Card title="Unparalleled DX" icon="seti:tsconfig"> + Type syntax you already know with safety unlike anything you've ever seen </Card> - <Card title="Add new content" icon="add-document"> - Add Markdown or MDX files to `src/content/docs` to create new pages. + <Card title="Faster. Everywhere." icon="rocket"> + [100x faster than + Zod](https://moltar.github.io/typescript-runtime-type-benchmarks/) with + editor performance that will remind you how autocomplete is supposed to feel </Card> - <Card title="Configure your site" icon="setting"> - Edit your `sidebar` and other config in `astro.config.mjs`. + <Card title="Clear and concise" icon="magnifier"> + Schemas are half as long and twice as readable with hovers that tell you + just what really matters </Card> - <Card title="Read the docs" icon="open-book"> - Learn more in [the Starlight Docs](https://starlight.astro.build/). + <Card title="Better errors" icon="error"> + Deeply customizable and composable messages with great defaults </Card> -</CardGrid> */} +</CardGrid> diff --git a/ark/docs/src/content/docs/intro/why.md b/ark/docs/src/content/docs/intro/why.md index dc58a21d5a..a720fcdeb4 100644 --- a/ark/docs/src/content/docs/intro/why.md +++ b/ark/docs/src/content/docs/intro/why.md @@ -5,13 +5,8 @@ description: A guide in my new Starlight docs site. ## Why use it? -- **Performance**: - - In editor: Types are 3x more efficient than Zod - - At runtime: 400x faster than Zod, 2000x faster than Yup - **Concision**: - Definitions: About 1/2 as long as equivalent Zod on average - Types: Tooltips are 1/5 the length of Zod on average - **Portability**: - Definitions are just strings and objects and are serializable by default. -- **Developer Experience**: - - With semantic validation and contextual autocomplete, ArkType's static parser is unlike anything you've ever seen. diff --git a/ark/fs/getCurrentLine.ts b/ark/fs/getCurrentLine.ts index 1009e3b1ab..4ce377279b 100644 --- a/ark/fs/getCurrentLine.ts +++ b/ark/fs/getCurrentLine.ts @@ -227,6 +227,4 @@ export const getCurrentLine = ( frames: 0, immediate: false } -): Location => { - return getLocationFromError(new Error(), offset) -} +): Location => getLocationFromError(new Error(), offset) diff --git a/ark/repo/.eslintrc.cjs b/ark/repo/.eslintrc.cjs index cc5161e2fa..9f1b9711ab 100644 --- a/ark/repo/.eslintrc.cjs +++ b/ark/repo/.eslintrc.cjs @@ -51,6 +51,7 @@ module.exports = defineConfig({ disallowPrototype: true } ], + "arrow-body-style": ["warn", "as-needed"], "@typescript-eslint/no-unused-vars": [ "warn", { @@ -59,10 +60,6 @@ module.exports = defineConfig({ ignoreRestSiblings: true } ], - "@typescript-eslint/explicit-module-boundary-types": [ - "warn", - { allowDirectConstAssertionInArrowFunctions: true } - ], "@typescript-eslint/default-param-last": "warn", "@typescript-eslint/no-empty-interface": "off", /** diff --git a/ark/repo/arkConfig.ts b/ark/repo/arkConfig.ts index 0ba82e660b..8396ee19d4 100644 --- a/ark/repo/arkConfig.ts +++ b/ark/repo/arkConfig.ts @@ -1,10 +1,5 @@ -// import { configure } from "arktype/config" -// // import { type } from "arktype" +import { configure } from "arktype/config" -// // const user = type("string") - -// configure({ -// domain: { -// description: (inner) => `my special ${inner.domain}` -// } -// }) +configure({ + jitless: true +}) diff --git a/ark/repo/build.ts b/ark/repo/build.ts index ef25fd305d..44f00486f4 100644 --- a/ark/repo/build.ts +++ b/ark/repo/build.ts @@ -1,8 +1,11 @@ +import { fromCwd, rmRf, shell, writeJson } from "@arktype/fs" import { symlinkSync, unlinkSync } from "fs" -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { fromCwd, shell, writeJson } from "../fs/api.js" +import { join } from "path" const isCjs = process.argv.includes("--cjs") || process.env.ARKTYPE_CJS +const outDir = fromCwd("out") + +rmRf(outDir) try { if (isCjs) { @@ -10,7 +13,7 @@ try { symlinkSync(`../repo/tsconfig.cjs.json`, "tsconfig.build.json") } shell("pnpm tsc --project tsconfig.build.json") - if (isCjs) writeJson(fromCwd("out", "package.json"), { type: "commonjs" }) + if (isCjs) writeJson(join(outDir, "package.json"), { type: "commonjs" }) } finally { if (isCjs) { unlinkSync("tsconfig.build.json") diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index d01f668c4e..c050722a2d 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,23 +1,34 @@ import { type } from "arktype" -// type Z = Type<{ age: number.default<5> }> +const parseBigint = type("string", "=>", (s, ctx) => { + try { + return BigInt(s) + } catch { + return ctx.error("a valid number") + } +}) -const f = (arg?: string) => {} +// or -const user = type({ - "+": "delete", - name: "string>10", - email: "email" - // age: ["number", "=", 5] +const parseBigint2 = type("string").pipe((s, ctx) => { + try { + return BigInt(s) + } catch { + return ctx.error("a valid number") + } }) -const out = user({ - name: "test", - email: "" +const Test = type({ + group: { + nested: { + value: parseBigint + } + } }) -if (out instanceof type.errors) { - console.log(out.summary) -} else { - console.log(out) +const myFunc = () => { + const out = Test({}) + if (out instanceof type.errors) return + + const value: bigint = out.group.nested.value } diff --git a/ark/schema/__tests__/parse.bench.ts b/ark/schema/__tests__/parse.bench.ts index 5f32a1932b..593f138a42 100644 --- a/ark/schema/__tests__/parse.bench.ts +++ b/ark/schema/__tests__/parse.bench.ts @@ -1,13 +1,12 @@ import { bench } from "@arktype/attest" import { schema } from "@arktype/schema" -bench("domain", () => { - return schema("string").infer -}).types([2, "instantiations"]) +bench("domain", () => schema("string").infer).types([2, "instantiations"]) -bench("intersection", () => { - return schema("string").and(schema("number")) -}).types([846, "instantiations"]) +bench("intersection", () => schema("string").and(schema("number"))).types([ + 846, + "instantiations" +]) bench("no assignment", () => { schema({ domain: "string", regex: "/.*/" }) diff --git a/ark/schema/api.ts b/ark/schema/api.ts index 5c20e0a7d9..4298fc1a27 100644 --- a/ark/schema/api.ts +++ b/ark/schema/api.ts @@ -23,7 +23,6 @@ export * from "./refinements/min.js" export * from "./refinements/minLength.js" export * from "./refinements/range.js" export * from "./refinements/regex.js" -export * from "./roots/discriminate.js" export * from "./roots/intersection.js" export * from "./roots/morph.js" export * from "./roots/root.js" diff --git a/ark/schema/constraint.ts b/ark/schema/constraint.ts index ba74af180d..f221cf2329 100644 --- a/ark/schema/constraint.ts +++ b/ark/schema/constraint.ts @@ -79,7 +79,12 @@ export abstract class RawPrimitiveConstraint< } compile(js: NodeCompiler): void { - js.compilePrimitive(this as never) + if (js.traversalKind === "Allows") js.return(this.compiledCondition) + else { + js.if(this.compiledNegation, () => + js.line(`${js.ctx}.error(${this.compiledErrorContext})`) + ) + } } get errorContext(): d["errorContext"] { diff --git a/ark/schema/inference.ts b/ark/schema/inference.ts index 3a3627da05..f0e9eed1d5 100644 --- a/ark/schema/inference.ts +++ b/ark/schema/inference.ts @@ -68,8 +68,7 @@ type inferRootBranch<schema, $> = : schema extends MorphSchema ? ( In: schema["in"] extends {} ? inferMorphChild<schema["in"], $> : unknown - ) => schema["out"] extends {} ? Out<inferMorphChild<schema["out"], $>> - : schema["morphs"] extends infer morph extends Morph ? + ) => schema["morphs"] extends infer morph extends Morph ? Out<inferMorphOut<morph>> : schema["morphs"] extends ( readonly [...unknown[], infer morph extends Morph] diff --git a/ark/schema/keywords/internal.ts b/ark/schema/keywords/internal.ts index 32974c4715..17bfb46d56 100644 --- a/ark/schema/keywords/internal.ts +++ b/ark/schema/keywords/internal.ts @@ -1,10 +1,14 @@ import type { Key } from "@arktype/util" import type { SchemaModule } from "../module.js" import { root, schemaScope } from "../scope.js" +// these are needed to create some internal types +import { arrayIndexMatcher } from "../structure/shared.js" +import "./tsKeywords.js" export interface internalKeywordExports { lengthBoundable: string | unknown[] propertyKey: Key + nonNegativeIntegerString: string } export type internalKeywords = SchemaModule<internalKeywordExports> @@ -12,7 +16,8 @@ export type internalKeywords = SchemaModule<internalKeywordExports> export const internalKeywords: internalKeywords = schemaScope( { lengthBoundable: ["string", Array], - propertyKey: ["string", "symbol"] + propertyKey: ["string", "symbol"], + nonNegativeIntegerString: { domain: "string", regex: arrayIndexMatcher } }, { prereducedAliases: true, diff --git a/ark/schema/keywords/parsing.ts b/ark/schema/keywords/parsing.ts index 1c35849416..24545298c5 100644 --- a/ark/schema/keywords/parsing.ts +++ b/ark/schema/keywords/parsing.ts @@ -7,21 +7,15 @@ import type { SchemaModule } from "../module.js" import type { Out } from "../roots/morph.js" import { root, schemaScope } from "../scope.js" import { tryParseDatePattern } from "./utils/date.js" +import { defineRegex } from "./utils/regex.js" const number = root.defineRoot({ - in: { - domain: "string", - regex: wellFormedNumberMatcher, - description: "a well-formed numeric string" - }, + in: defineRegex(wellFormedNumberMatcher, "a well-formed numeric string"), morphs: (s: string) => Number.parseFloat(s) }) const integer = root.defineRoot({ - in: { - domain: "string", - regex: wellFormedIntegerMatcher - }, + in: defineRegex(wellFormedIntegerMatcher, "a well-formed integer string"), morphs: (s: string, ctx) => { if (!isWellFormedInteger(s)) return ctx.error("a well-formed integer string") @@ -36,10 +30,7 @@ const integer = root.defineRoot({ }) const url = root.defineRoot({ - in: { - domain: "string", - description: "a valid URL" - }, + in: "string", morphs: (s: string, ctx) => { try { return new URL(s) @@ -50,11 +41,14 @@ const url = root.defineRoot({ }) const json = root.defineRoot({ - in: { - domain: "string", - description: "a JSON-parsable string" - }, - morphs: (s: string): unknown => JSON.parse(s) + in: "string", + morphs: (s: string, ctx): object => { + try { + return JSON.parse(s) + } catch { + return ctx.error("a valid JSON string") + } + } }) const date = root.defineRoot({ @@ -70,7 +64,7 @@ export type parsingExports = { number: (In: string) => Out<number> integer: (In: string) => Out<number> date: (In: string) => Out<Date> - json: (In: string) => Out<unknown> + json: (In: string) => Out<object> } export type parsing = SchemaModule<parsingExports> diff --git a/ark/schema/keywords/utils/ip.ts b/ark/schema/keywords/utils/ip.ts new file mode 100644 index 0000000000..5b1640fbeb --- /dev/null +++ b/ark/schema/keywords/utils/ip.ts @@ -0,0 +1,27 @@ +import { root } from "../../scope.js" +import { defineRegex } from "./regex.js" + +// Based on https://github.com/validatorjs/validator.js/blob/master/src/lib/isIP.js +const ipv4Segment = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])" +const ipv4Address = `(${ipv4Segment}[.]){3}${ipv4Segment}` +const ipv4Matcher = new RegExp(`^${ipv4Address}$`) + +export const ipv4 = defineRegex(ipv4Matcher, "a valid IPv4 address") + +const ipv6Segment = "(?:[0-9a-fA-F]{1,4})" +const ipv6Matcher = new RegExp( + "^(" + + `(?:${ipv6Segment}:){7}(?:${ipv6Segment}|:)|` + + `(?:${ipv6Segment}:){6}(?:${ipv4Address}|:${ipv6Segment}|:)|` + + `(?:${ipv6Segment}:){5}(?::${ipv4Address}|(:${ipv6Segment}){1,2}|:)|` + + `(?:${ipv6Segment}:){4}(?:(:${ipv6Segment}){0,1}:${ipv4Address}|(:${ipv6Segment}){1,3}|:)|` + + `(?:${ipv6Segment}:){3}(?:(:${ipv6Segment}){0,2}:${ipv4Address}|(:${ipv6Segment}){1,4}|:)|` + + `(?:${ipv6Segment}:){2}(?:(:${ipv6Segment}){0,3}:${ipv4Address}|(:${ipv6Segment}){1,5}|:)|` + + `(?:${ipv6Segment}:){1}(?:(:${ipv6Segment}){0,4}:${ipv4Address}|(:${ipv6Segment}){1,6}|:)|` + + `(?::((?::${ipv6Segment}){0,5}:${ipv4Address}|(?::${ipv6Segment}){1,7}|:))` + + ")(%[0-9a-zA-Z-.:]{1,})?$" +) + +export const ipv6 = defineRegex(ipv6Matcher, "a valid IPv6 address") + +export const ip = root.defineRoot([ipv4, ipv6]) diff --git a/ark/schema/keywords/utils/regex.ts b/ark/schema/keywords/utils/regex.ts new file mode 100644 index 0000000000..6e06d2991d --- /dev/null +++ b/ark/schema/keywords/utils/regex.ts @@ -0,0 +1,15 @@ +import type { NormalizedRegexSchema } from "../../refinements/regex.js" +import { root } from "../../scope.js" + +export const defineRegex = ( + regex: RegExp, + description: string +): { domain: "string"; regex: NormalizedRegexSchema } => + root.defineRoot({ + domain: "string", + regex: { + rule: regex.source, + flags: regex.flags, + description + } + }) diff --git a/ark/schema/keywords/validation.ts b/ark/schema/keywords/validation.ts index 5cc65d3943..555683b200 100644 --- a/ark/schema/keywords/validation.ts +++ b/ark/schema/keywords/validation.ts @@ -1,6 +1,8 @@ import type { SchemaModule } from "../module.js" import { root, schemaScope } from "../scope.js" import { creditCardMatcher, isLuhnValid } from "./utils/creditCard.js" +import { ip } from "./utils/ip.js" +import { defineRegex } from "./utils/regex.js" // Non-trivial expressions should have an explanation or attribution @@ -22,37 +24,22 @@ const url = root.defineRoot({ // https://www.regular-expressions.info/email.html const emailMatcher = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ -const email = root.defineRoot({ - domain: "string", - regex: { - rule: emailMatcher.source, - description: "a valid email" - } -}) +const email = defineRegex(emailMatcher, "a valid email") const uuidMatcher = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/ // https://github.com/validatorjs/validator.js/blob/master/src/lib/isUUID.js -const uuid = root.defineRoot({ - domain: "string", - regex: { - rule: uuidMatcher.source, - description: "a valid UUID" - } -}) +const uuid = defineRegex(uuidMatcher, "a valid UUID") const semverMatcher = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ // https://semver.org/ -const semver = root.defineRoot({ - domain: "string", - regex: { - rule: semverMatcher.source, - description: "a valid semantic version (see https://semver.org/)" - } -}) +const semver = defineRegex( + semverMatcher, + "a valid semantic version (see https://semver.org/)" +) const creditCard = root.defineRoot({ domain: "string", @@ -76,6 +63,7 @@ export interface validationExports { uuid: string url: string semver: string + ip: string integer: number } @@ -83,37 +71,16 @@ export type validation = SchemaModule<validationExports> export const validation: validation = schemaScope( { - alpha: { - domain: "string", - regex: /^[A-Za-z]*$/, - description: "only letters" - }, - alphanumeric: { - domain: "string", - regex: { - rule: /^[A-Za-z\d]*$/.source, - description: "only letters and digits" - } - }, - lowercase: { - domain: "string", - regex: { - rule: /^[a-z]*$/.source, - description: "only lowercase letters" - } - }, - uppercase: { - domain: "string", - regex: { - rule: /^[A-Za-z]*$/.source, - description: "only uppercase letters" - } - }, + alpha: defineRegex(/^[A-Za-z]*$/, "only letters"), + alphanumeric: defineRegex(/^[A-Za-z\d]*$/, "only letters and digits"), + lowercase: defineRegex(/^[a-z]*$/, "only lowercase letters"), + uppercase: defineRegex(/^[A-Z]*$/, "only uppercase letters"), creditCard, email, uuid, url, semver, + ip, integer: { domain: "number", divisor: 1 diff --git a/ark/schema/node.ts b/ark/schema/node.ts index 335d222ac4..b2d95295c0 100644 --- a/ark/schema/node.ts +++ b/ark/schema/node.ts @@ -3,16 +3,18 @@ import { flatMorph, includes, isArray, + isEmptyObject, shallowClone, throwError, type Dict, type Guardable, type Json, + type Key, type conform, type listable } from "@arktype/util" import type { BaseConstraint } from "./constraint.js" -import type { Inner, Node, reducibleKindOf } from "./kinds.js" +import type { Inner, MutableInner, Node, reducibleKindOf } from "./kinds.js" import type { BaseRoot, Root } from "./roots/root.js" import type { UnitNode } from "./roots/unit.js" import type { RawRootScope } from "./scope.js" @@ -49,7 +51,9 @@ export abstract class BaseNode< > extends Callable<(data: d["prerequisite"]) => unknown, attachmentsOf<d>> { constructor(public attachments: UnknownAttachments) { super( - (data: any) => { + // pipedFromCtx allows us internally to reuse TraversalContext + // through pipes and keep track of piped paths. It is not exposed + (data: any, pipedFromCtx?: TraversalContext) => { if ( !this.includesMorph && !this.allowsRequiresContext && @@ -57,17 +61,14 @@ export abstract class BaseNode< ) return data + if (pipedFromCtx) return this.traverseApply(data, pipedFromCtx) + const ctx = new TraversalContext(data, this.$.resolvedConfig) this.traverseApply(data, ctx) return ctx.finalize() }, { attach: attachments as never } ) - this.contributesReferencesById = - this.id in this.referencesByName ? - this.referencesByName - : { ...this.referencesByName, [this.id]: this as never } - this.contributesReferences = Object.values(this.contributesReferencesById) } abstract traverseAllows: TraverseAllows<d["prerequisite"]> @@ -86,15 +87,15 @@ export abstract class BaseNode< (this.hasKind("predicate") && this.inner.predicate.length !== 1) || this.kind === "alias" || this.children.some(child => child.allowsRequiresContext) - readonly referencesByName: Record<string, BaseNode> = this.children.reduce( - (result, child) => Object.assign(result, child.contributesReferencesById), - {} - ) - readonly references: readonly BaseNode[] = Object.values( - this.referencesByName + readonly referencesById: Record<string, BaseNode> = this.children.reduce( + (result, child) => Object.assign(result, child.referencesById), + { [this.id]: this } ) - readonly contributesReferencesById: Record<string, BaseNode> - readonly contributesReferences: readonly BaseNode[] + + get references(): BaseNode[] { + return Object.values(this.referencesById) + } + readonly precedence: number = precedenceOfKind(this.kind) jit = false @@ -140,10 +141,10 @@ export abstract class BaseNode< const ioInner: Record<any, unknown> = {} for (const [k, v] of this.entries) { - const keySchemainition = this.impl.keys[k] - if (keySchemainition.meta) continue + const keySchemaImplementation = this.impl.keys[k] + if (keySchemaImplementation.meta) continue - if (keySchemainition.child) { + if (keySchemaImplementation.child) { const childValue = v as listable<BaseNode> ioInner[k] = isArray(childValue) ? @@ -209,7 +210,7 @@ export abstract class BaseNode< firstReference<narrowed>( filter: Guardable<BaseNode, conform<narrowed, BaseNode>> ): narrowed | undefined { - return this.references.find(filter as never) as never + return this.references.find(n => n !== this && filter(n)) as never } firstReferenceOrThrow<narrowed extends BaseNode>( @@ -234,49 +235,74 @@ export abstract class BaseNode< ) } - transform( - mapper: DeepNodeTransformation, - shouldTransform: ShouldTransformFn - ): Node<reducibleKindOf<this["kind"]>> { - return this._transform(mapper, shouldTransform, { seen: {} }) as never + transform<mapper extends DeepNodeTransformation>( + mapper: mapper, + opts?: DeepNodeTransformOptions + ): Node<reducibleKindOf<this["kind"]>> | Extract<ReturnType<mapper>, null> { + return this._transform(mapper, { + seen: {}, + path: [], + shouldTransform: opts?.shouldTransform ?? (() => true) + }) as never } - private _transform( + protected _transform( mapper: DeepNodeTransformation, - shouldTransform: ShouldTransformFn, ctx: DeepNodeTransformationContext - ): BaseNode { + ): BaseNode | null { if (ctx.seen[this.id]) // TODO: remove cast by making lazilyResolve more flexible // TODO: if each transform has a unique base id, could ensure // these don't create duplicates return this.$.lazilyResolve(ctx.seen[this.id]! as never) - if (!shouldTransform(this as never, ctx)) return this + if (!ctx.shouldTransform(this as never, ctx)) return this + + let transformedNode: BaseRoot | undefined - ctx.seen[this.id] = () => node + ctx.seen[this.id] = () => transformedNode const innerWithTransformedChildren = flatMorph( this.inner as Dict, - (k, v) => [ - k, - this.impl.keys[k].child ? - isArray(v) ? - v.map(node => - (node as BaseNode)._transform(mapper, shouldTransform, ctx) - ) - : (v as BaseNode)._transform(mapper, shouldTransform, ctx) - : v - ] + (k, v) => { + if (!this.impl.keys[k].child) return [k, v] + const children = v as listable<BaseNode> + if (!isArray(children)) { + const transformed = children._transform(mapper, ctx) + return transformed ? [k, transformed] : [] + } + const transformed = children.flatMap(n => { + const transformedChild = n._transform(mapper, ctx) + return transformedChild ?? [] + }) + return transformed.length ? [k, transformed] : [] + } ) delete ctx.seen[this.id] - const node = this.$.node( + const transformedInner = mapper( this.kind, - mapper(this.kind, innerWithTransformedChildren as never, ctx) as never + innerWithTransformedChildren as never, + ctx ) - return node + if (transformedInner === null) return null + // TODO: more robust checks for pruned inner + if (isEmptyObject(transformedInner)) return null + + if ( + (this.kind === "required" || + this.kind === "optional" || + this.kind === "index") && + !("value" in transformedInner) + ) + return null + if (this.kind === "morph") { + ;(transformedInner as MutableInner<"morph">).in ??= this.$.keywords + .unknown as never + } + + return (transformedNode = this.$.node(this.kind, transformedInner) as never) } configureShallowDescendants(configOrDescription: BaseMeta | string): this { @@ -284,24 +310,30 @@ export abstract class BaseNode< typeof configOrDescription === "string" ? { description: configOrDescription } : (configOrDescription as never) - return this.transform( - (kind, inner) => ({ ...inner, ...config }), - node => node.kind !== "structure" - ) as never + return this.transform((kind, inner) => ({ ...inner, ...config }), { + shouldTransform: node => node.kind !== "structure" + }) as never } } +export type DeepNodeTransformOptions = { + shouldTransform: ShouldTransformFn +} + export type ShouldTransformFn = ( node: BaseNode, ctx: DeepNodeTransformationContext ) => boolean export type DeepNodeTransformationContext = { - seen: { [originalId: string]: (() => BaseNode) | undefined } + /** a literal key or a node representing the key of an index signature */ + path: Array<Key | BaseNode> + seen: { [originalId: string]: (() => BaseNode | undefined) | undefined } + shouldTransform: ShouldTransformFn } export type DeepNodeTransformation = <kind extends NodeKind>( kind: kind, inner: Inner<kind>, ctx: DeepNodeTransformationContext -) => Inner<kind> +) => Inner<kind> | null diff --git a/ark/schema/refinements/exactLength.ts b/ark/schema/refinements/exactLength.ts index 1ecfb53af9..628330203a 100644 --- a/ark/schema/refinements/exactLength.ts +++ b/ark/schema/refinements/exactLength.ts @@ -42,7 +42,7 @@ export const exactLengthImplementation: nodeImplementationOf<ExactLengthDeclarat intersections: { exactLength: (l, r, ctx) => new Disjoint({ - "[length]": { + '["length"]': { unit: { l: ctx.$.node("unit", { unit: l.rule }), r: ctx.$.node("unit", { unit: r.rule }) diff --git a/ark/schema/roots/basis.ts b/ark/schema/roots/basis.ts index 6c811d9db2..40b86dfc4e 100644 --- a/ark/schema/roots/basis.ts +++ b/ark/schema/roots/basis.ts @@ -29,6 +29,11 @@ export abstract class RawBasis< } compile(js: NodeCompiler): void { - js.compilePrimitive(this as never) + if (js.traversalKind === "Allows") js.return(this.compiledCondition) + else { + js.if(this.compiledNegation, () => + js.line(`${js.ctx}.error(${this.compiledErrorContext})`) + ) + } } } diff --git a/ark/schema/roots/discriminate.ts b/ark/schema/roots/discriminate.ts deleted file mode 100644 index 95af5416c0..0000000000 --- a/ark/schema/roots/discriminate.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { - type Domain, - type SerializedPrimitive, - compileSerializedValue, - entriesOf, - isKeyOf, - type keySet, - type mutable, - type show, - throwInternalError -} from "@arktype/util" -import { Disjoint, type SerializedPath } from "../shared/disjoint.js" -import { intersectNodesRoot } from "../shared/intersections.js" -import type { UnionChildNode } from "./union.js" - -export type CaseKey<kind extends DiscriminantKind = DiscriminantKind> = - DiscriminantKind extends kind ? string : DiscriminantKinds[kind] | "default" - -export type Discriminant<kind extends DiscriminantKind = DiscriminantKind> = - Readonly<{ - readonly path: string[] - readonly kind: kind - readonly cases: DiscriminatedCases<kind> - // TODO: add default here? - readonly isPureRootLiteral: boolean - }> - -export type DiscriminatedCases< - kind extends DiscriminantKind = DiscriminantKind -> = Readonly<{ - [caseKey in CaseKey<kind>]: Discriminant | UnionChildNode[] -}> - -type DiscriminantKey = `${SerializedPath}${DiscriminantKind}` - -type CasesBySpecifier = { - [k in DiscriminantKey]?: Record<string, UnionChildNode[]> -} - -export type DiscriminantKinds = { - domain: Domain - value: SerializedPrimitive -} - -const discriminantKinds: keySet<DiscriminantKind> = { - domain: 1, - value: 1 -} - -export type DiscriminantKind = show<keyof DiscriminantKinds> - -const parseDiscriminantKey = (key: DiscriminantKey) => { - const lastPathIndex = key.lastIndexOf("]") - return [ - JSON.parse(key.slice(0, lastPathIndex + 1)), - key.slice(lastPathIndex + 1) - ] as [path: string[], kind: DiscriminantKind] -} - -const discriminantCache = new Map< - readonly UnionChildNode[], - Discriminant | null ->() - -export const discriminate = ( - branches: readonly UnionChildNode[] -): Discriminant | null => { - if (branches.length < 2) return null - - const cached = discriminantCache.get(branches) - if (cached !== undefined) return cached - - // const pureValueBranches = branches.flatMap((branch) => - // branch.unit ? branch.unit : [] - // ) - // if (pureValueBranches.length === branches.length) { - // const cases: DiscriminatedCases = transform( - // pureValueBranches, - // ([i, valueNode]) => [valueNode.serialized, [branches[i]]] - // ) - // return { - // path: [], - // kind: "value", - // cases, - // isPureRootLiteral: true - // } - // } - const casesBySpecifier: CasesBySpecifier = {} - for (let lIndex = 0; lIndex < branches.length - 1; lIndex++) { - const l = branches[lIndex] - for (let rIndex = lIndex + 1; rIndex < branches.length; rIndex++) { - const r = branches[rIndex] - const result = intersectNodesRoot(l, r, l.$) - if (!(result instanceof Disjoint)) continue - - for (const { path, kind, disjoint } of result.flat) { - if (!isKeyOf(kind, discriminantKinds)) continue - - const qualifiedDiscriminant: DiscriminantKey = `${path}${kind}` - let lSerialized: string - let rSerialized: string - if (kind === "domain") { - lSerialized = disjoint.l as Domain - rSerialized = disjoint.r as Domain - } else if (kind === "value") { - lSerialized = compileSerializedValue(disjoint.l) - rSerialized = compileSerializedValue(disjoint.r) - } else { - return throwInternalError( - `Unexpected attempt to discriminate disjoint kind '${kind}'` - ) - } - if (!casesBySpecifier[qualifiedDiscriminant]) { - casesBySpecifier[qualifiedDiscriminant] = { - [lSerialized]: [l], - [rSerialized]: [r] - } - continue - } - const cases = casesBySpecifier[qualifiedDiscriminant]! - if (!isKeyOf(lSerialized, cases)) cases[lSerialized] = [l] - else if (!cases[lSerialized].includes(l)) cases[lSerialized].push(l) - - if (!isKeyOf(rSerialized, cases)) cases[rSerialized] = [r] - else if (!cases[rSerialized].includes(r)) cases[rSerialized].push(r) - } - } - } - // TODO: determinstic? Update cache key? - const bestDiscriminantEntry = entriesOf(casesBySpecifier) - .sort((a, b) => Object.keys(a[1]).length - Object.keys(b[1]).length) - .at(-1) - if (!bestDiscriminantEntry) { - discriminantCache.set(branches, null) - return null - } - const [specifier, predicateCases] = bestDiscriminantEntry - const [path, kind] = parseDiscriminantKey(specifier) - const cases: mutable<DiscriminatedCases> = {} - for (const k in predicateCases) { - const subdiscriminant = discriminate(predicateCases[k]) - cases[k] = subdiscriminant ?? predicateCases[k] - } - const discriminant: Discriminant = { - kind, - path, - cases, - isPureRootLiteral: false - } - discriminantCache.set(branches, discriminant) - return discriminant -} - -// // TODO: if deeply includes morphs? -// const writeUndiscriminableMorphUnionMessage = <path extends string>( -// path: path -// ) => -// `${ -// path === "/" ? "A" : `At ${path}, a` -// } union including one or more morphs must be discriminable` as const diff --git a/ark/schema/roots/intersection.ts b/ark/schema/roots/intersection.ts index b1ffa10df5..a3f3b0d4ce 100644 --- a/ark/schema/roots/intersection.ts +++ b/ark/schema/roots/intersection.ts @@ -332,9 +332,7 @@ export const intersectionImplementation: nodeImplementationOf<IntersectionDeclar problem: ctx => `must be...\n${ctx.expected}` }, intersections: { - intersection: (l, r, ctx) => { - return intersectIntersections(l, r, ctx) - }, + intersection: (l, r, ctx) => intersectIntersections(l, r, ctx), ...defineRightwardIntersections("intersection", (l, r, ctx) => { // if l is unknown, return r if (l.children.length === 0) return r diff --git a/ark/schema/roots/morph.ts b/ark/schema/roots/morph.ts index bfdde87d9d..24a2358a3e 100644 --- a/ark/schema/roots/morph.ts +++ b/ark/schema/roots/morph.ts @@ -12,7 +12,7 @@ import { } from "@arktype/util" import type { of } from "../ast.js" import type { type } from "../inference.js" -import type { Node, NodeSchema, RootSchema } from "../kinds.js" +import type { Node, NodeSchema } from "../kinds.js" import type { StaticArkOption } from "../scope.js" import type { NodeCompiler } from "../shared/compile.js" import type { BaseMeta, declareNode } from "../shared/declare.js" @@ -28,8 +28,9 @@ import type { TraverseAllows, TraverseApply } from "../shared/traversal.js" +import { hasArkKind } from "../shared/utils.js" import type { DefaultableAst } from "../structure/optional.js" -import { BaseRoot, type schemaKindRightOf } from "./root.js" +import { BaseRoot, type Root, type schemaKindRightOf } from "./root.js" import { defineRightwardIntersections } from "./utils.js" export type MorphInputKind = schemaKindRightOf<"morph"> @@ -53,14 +54,12 @@ export type MorphAst<i = any, o = any> = (In: i) => Out<o> export interface MorphInner extends BaseMeta { readonly in: MorphInputNode - readonly out?: BaseRoot - readonly morphs: readonly Morph[] + readonly morphs: array<Morph | Root> } export interface MorphSchema extends BaseMeta { readonly in: MorphInputSchema - readonly out?: RootSchema | undefined - readonly morphs: listable<Morph> + readonly morphs: listable<Morph | Root> } export interface MorphDeclaration @@ -81,20 +80,12 @@ export const morphImplementation: nodeImplementationOf<MorphDeclaration> = child: true, parse: (schema, ctx) => ctx.$.node(morphInputKinds, schema) }, - out: { - child: true, - parse: (schema, ctx) => { - if (schema === undefined) return - const out = ctx.$.schema(schema) - return out.kind === "intersection" && out.children.length === 0 ? - // ignore unknown as an output validator - undefined - : out - } - }, morphs: { parse: arrayFrom, - serialize: morphs => morphs.map(registeredReference) + serialize: morphs => + morphs.map(m => + hasArkKind(m, "root") ? m.json : registeredReference(m) + ) } }, normalize: schema => schema, @@ -109,21 +100,14 @@ export const morphImplementation: nodeImplementationOf<MorphDeclaration> = return throwParseError("Invalid intersection of morphs") const inTersection = intersectNodes(l.in, r.in, ctx) if (inTersection instanceof Disjoint) return inTersection - const out = - l.out ? - r.out ? - intersectNodes(l.out, r.out, ctx) - : l.out - : r.out - if (out instanceof Disjoint) return out + // in case from is a union, we need to distribute the branches // to can be a union as any schema is allowed return ctx.$.schema( inTersection.branches.map(inBranch => ctx.$.node("morph", { morphs: l.morphs, - in: inBranch, - out + in: inBranch }) ) ) @@ -150,22 +134,14 @@ export const morphImplementation: nodeImplementationOf<MorphDeclaration> = }) export class MorphNode extends BaseRoot<MorphDeclaration> { - serializedMorphs: string[] = (this.json as any).morphs + serializedMorphs: string[] = this.morphs.map(registeredReference) compiledMorphs = `[${this.serializedMorphs}]` - outValidator: TraverseApply | null = this.inner.out?.traverseApply ?? null - - private queueArgs: Parameters<TraversalContext["queueMorphs"]> = [ - this.morphs, - this.outValidator ? { outValidator: this.outValidator } : {} - ] - - private queueArgsReference = registeredReference(this.queueArgs) traverseAllows: TraverseAllows = (data, ctx) => this.in.traverseAllows(data, ctx) traverseApply: TraverseApply = (data, ctx) => { - ctx.queueMorphs(...this.queueArgs) + ctx.queueMorphs(this.morphs) this.in.traverseApply(data, ctx) } @@ -176,7 +152,7 @@ export class MorphNode extends BaseRoot<MorphDeclaration> { js.return(js.invoke(this.in)) return } - js.line(`ctx.queueMorphs(...${this.queueArgsReference})`) + js.line(`ctx.queueMorphs(${this.compiledMorphs})`) js.line(js.invoke(this.in)) } @@ -184,8 +160,15 @@ export class MorphNode extends BaseRoot<MorphDeclaration> { return this.inner.in } + get validatedOut(): BaseRoot | undefined { + const lastMorph = this.inner.morphs.at(-1) + return hasArkKind(lastMorph, "root") ? + (lastMorph?.out as BaseRoot) + : undefined + } + override get out(): BaseRoot { - return (this.inner.out?.out as BaseRoot) ?? this.$.keywords.unknown.raw + return this.validatedOut ?? this.$.keywords.unknown.raw } rawKeyOf(): BaseRoot { diff --git a/ark/schema/roots/proto.ts b/ark/schema/roots/proto.ts index 4da828d269..09501ff9e8 100644 --- a/ark/schema/roots/proto.ts +++ b/ark/schema/roots/proto.ts @@ -1,14 +1,14 @@ import { - type BuiltinObjectKind, - type Constructor, - type Key, - type array, builtinConstructors, constructorExtends, getExactBuiltinConstructorName, objectKindDescriptions, objectKindOrDomainOf, - prototypeKeysOf + prototypeKeysOf, + type BuiltinObjectKind, + type Constructor, + type Key, + type array } from "@arktype/util" import type { BaseMeta, declareNode } from "../shared/declare.js" import { Disjoint } from "../shared/disjoint.js" @@ -19,6 +19,7 @@ import { } from "../shared/implement.js" import type { TraverseAllows } from "../shared/traversal.js" import { RawBasis } from "./basis.js" +import type { DomainNode } from "./domain.js" export interface ProtoInner<proto extends Constructor = Constructor> extends BaseMeta { @@ -81,7 +82,11 @@ export const protoImplementation: nodeImplementationOf<ProtoDeclaration> = domain: (proto, domain, ctx) => domain.domain === "object" ? proto - : Disjoint.from("domain", ctx.$.keywords.object as never, domain) + : Disjoint.from( + "domain", + ctx.$.keywords.object.raw as DomainNode, + domain + ) } }) diff --git a/ark/schema/roots/root.ts b/ark/schema/roots/root.ts index 17370d8d30..360a20c364 100644 --- a/ark/schema/roots/root.ts +++ b/ark/schema/roots/root.ts @@ -230,7 +230,7 @@ export abstract class BaseRoot< omit(inner as StructureInner, { undeclared: 1 }) : { ...inner, undeclared } : inner, - node => !includes(structuralKinds, node.kind) + { shouldTransform: node => !includes(structuralKinds, node.kind) } ) } diff --git a/ark/schema/roots/union.ts b/ark/schema/roots/union.ts index d749204139..d25242c31c 100644 --- a/ark/schema/roots/union.ts +++ b/ark/schema/roots/union.ts @@ -1,8 +1,26 @@ -import { appendUnique, groupBy, isArray, type array } from "@arktype/util" +import { + appendUnique, + cached, + compileLiteralPropAccess, + domainDescriptions, + entriesOf, + flatMorph, + groupBy, + isArray, + isKeyOf, + printable, + throwInternalError, + type Domain, + type Json, + type SerializedPrimitive, + type array, + type keySet, + type show +} from "@arktype/util" import type { Node, NodeSchema } from "../kinds.js" import type { NodeCompiler } from "../shared/compile.js" import type { BaseMeta, declareNode } from "../shared/declare.js" -import { Disjoint } from "../shared/disjoint.js" +import { Disjoint, type SerializedPath } from "../shared/disjoint.js" import type { ArkError } from "../shared/errors.js" import { implementNode, @@ -13,7 +31,10 @@ import { } from "../shared/implement.js" import { intersectNodes, intersectNodesRoot } from "../shared/intersections.js" import type { TraverseAllows, TraverseApply } from "../shared/traversal.js" +import type { TraversalPath } from "../shared/utils.js" +import type { DomainInner, DomainNode } from "./domain.js" import { BaseRoot, type schemaKindRightOf } from "./root.js" +import type { UnitNode } from "./unit.js" import { defineRightwardIntersections } from "./utils.js" export type UnionChildKind = schemaKindRightOf<"union"> | "alias" @@ -94,9 +115,8 @@ export const unionImplementation: nodeImplementationOf<UnionDeclaration> = ) }, defaults: { - description: node => { - return describeBranches(node.branches.map(branch => branch.description)) - }, + description: node => + describeBranches(node.branches.map(branch => branch.description)), expected: ctx => { const byPath = groupBy(ctx.errors, "propString") as Record< string, @@ -111,13 +131,12 @@ export const unionImplementation: nodeImplementationOf<UnionDeclaration> = appendUnique(branchesAtPath, errorAtPath.expected) ) const expected = describeBranches(branchesAtPath) - const actual = errors.reduce( - (acc, e) => - e.actual && !acc.includes(e.actual) ? - `${acc && `${acc}, `}${e.actual}` - : acc, - "" - ) + // if there are multiple actual descriptions that differ, + // just fall back to printable, which is the most specific + const actual = + errors.every(e => e.actual === errors[0].actual) ? + errors[0].actual + : printable(errors[0].data) return `${path && `${path} `}must be ${expected}${ actual && ` (was ${actual})` }` @@ -173,7 +192,12 @@ export class UnionNode extends BaseRoot<UnionDeclaration> { this.branches[0].hasUnit(false) && this.branches[1].hasUnit(true) - discriminant = null + unitBranches = this.branches.filter((n): n is UnitNode => n.hasKind("unit")) + + discriminant = this.discriminate() + discriminantJson = + this.discriminant ? discriminantToJson(this.discriminant) : null + expression: string = this.isNever ? "never" : this.isBoolean ? "boolean" @@ -195,6 +219,54 @@ export class UnionNode extends BaseRoot<UnionDeclaration> { } compile(js: NodeCompiler): void { + if ( + !this.discriminant || + // if we have a union of two units like `boolean`, the + // undiscriminated compilation will be just as fast + (this.unitBranches.length === this.branches.length && + this.branches.length === 2) + ) + return this.compileIndiscriminable(js) + + // we need to access the path as optional so we don't throw if it isn't present + const condition = this.discriminant.path.reduce( + (acc, segment) => acc + compileLiteralPropAccess(segment, true), + this.discriminant.kind === "domain" ? "typeof data" : "data" + ) + + const cases = this.discriminant.cases + + const caseKeys = Object.keys(cases) + + js.block(`switch(${condition})`, () => { + for (const k in cases) { + const v = cases[k] + const caseCondition = k === "default" ? "default" : `case ${k}` + js.line(`${caseCondition}: return ${v === true ? v : js.invoke(v)}`) + } + + return js + }) + + if (js.traversalKind === "Allows") { + js.return(false) + return + } + + const expected = describeBranches( + this.discriminant.kind === "domain" ? + caseKeys.map(k => domainDescriptions[k.slice(1, -1) as Domain]) + : caseKeys + ) + + js.line(`ctx.error({ + expected: ${JSON.stringify(expected)}, + actual: ${condition}, + relativePath: ${JSON.stringify(this.discriminant.path)} +})`) + } + + private compileIndiscriminable(js: NodeCompiler): void { if (js.traversalKind === "Apply") { js.const("errors", "[]") this.branches.forEach(branch => @@ -227,8 +299,123 @@ export class UnionNode extends BaseRoot<UnionDeclaration> { // already collapsed to a single keyword return this.isBoolean ? "boolean" : super.nestableExpression } + + @cached + discriminate(): Discriminant | null { + if (this.branches.length < 2) return null + if (this.unitBranches.length === this.branches.length) { + const cases = flatMorph(this.unitBranches, (i, unit) => [ + `${unit.serializedValue}`, + true as const + ]) + + return { + path: [], + kind: "unit", + cases + } + } + const casesBySpecifier: CasesBySpecifier = {} + for (let lIndex = 0; lIndex < this.branches.length - 1; lIndex++) { + const l = this.branches[lIndex] + for (let rIndex = lIndex + 1; rIndex < this.branches.length; rIndex++) { + const r = this.branches[rIndex] + const result = intersectNodesRoot(l, r, l.$) + if (!(result instanceof Disjoint)) continue + + for (const { path, kind, disjoint } of result.flat) { + if (!isKeyOf(kind, discriminantKinds)) continue + + const qualifiedDiscriminant: DiscriminantKey = `${path}${kind}` + let lSerialized: string + let rSerialized: string + if (kind === "domain") { + lSerialized = `"${(disjoint.l as DomainNode).domain}"` + rSerialized = `"${(disjoint.r as DomainNode).domain}"` + } else if (kind === "unit") { + lSerialized = (disjoint.l as UnitNode).serializedValue as never + rSerialized = (disjoint.r as UnitNode).serializedValue as never + } else { + return throwInternalError( + `Unexpected attempt to discriminate disjoint kind '${kind}'` + ) + } + if (!casesBySpecifier[qualifiedDiscriminant]) { + casesBySpecifier[qualifiedDiscriminant] = { + [lSerialized]: [l], + [rSerialized]: [r] + } + continue + } + const cases = casesBySpecifier[qualifiedDiscriminant]! + if (!isKeyOf(lSerialized, cases)) cases[lSerialized] = [l] + else if (!cases[lSerialized].includes(l)) cases[lSerialized].push(l) + + if (!isKeyOf(rSerialized, cases)) cases[rSerialized] = [r] + else if (!cases[rSerialized].includes(r)) cases[rSerialized].push(r) + } + } + } + + const bestDiscriminantEntry = entriesOf(casesBySpecifier) + .sort((a, b) => Object.keys(a[1]).length - Object.keys(b[1]).length) + .at(-1) + + if (!bestDiscriminantEntry) return null + + const [specifier, bestCases] = bestDiscriminantEntry + const [path, kind] = parseDiscriminantKey(specifier) + + let defaultBranches = [...this.branches] + + const cases = flatMorph(bestCases, (k, caseBranches) => { + const prunedBranches: BaseRoot[] = [] + defaultBranches = defaultBranches.filter(n => !caseBranches.includes(n)) + for (const branch of caseBranches) { + const pruned = pruneDiscriminant(kind, path, branch) + // if any branch of the union has no constraints (i.e. is unknown) + // return it right away + if (pruned === null) return [k, true as const] + prunedBranches.push(pruned) + } + + const caseNode = + prunedBranches.length === 1 ? + prunedBranches[0] + : this.$.node("union", prunedBranches) + + Object.assign(this.referencesById, caseNode.referencesById) + + return [k, caseNode] + }) + + if (defaultBranches.length) { + cases.default = this.$.node("union", defaultBranches, { + prereduced: true + }) + + Object.assign(this.referencesById, cases.default.referencesById) + } + + return { + kind, + path, + cases + } + } } +const discriminantToJson = (discriminant: Discriminant): Json => ({ + kind: discriminant.kind, + path: discriminant.path, + cases: flatMorph(discriminant.cases, (k, node) => [ + k, + node === true ? node + : node.hasKind("union") && node.discriminantJson ? node.discriminantJson + : node.json + ]) +}) + const describeBranches = (descriptions: string[]) => { if (descriptions.length === 0) return "never" @@ -249,84 +436,6 @@ const describeBranches = (descriptions: string[]) => { return description } -// private static compileDiscriminatedLiteral(cases: DiscriminatedCases) { -// // TODO: error messages for traversal -// const caseKeys = Object.keys(cases) -// if (caseKeys.length === 2) { -// return `if( ${this.argName} !== ${caseKeys[0]} && ${this.argName} !== ${caseKeys[1]}) { -// return false -// }` -// } -// // for >2 literals, we fall through all cases, breaking on the last -// const compiledCases = -// caseKeys.map((k) => ` case ${k}:`).join("\n") + " break" -// // if none of the cases are met, the check fails (this is optimal for perf) -// return `switch(${this.argName}) { -// ${compiledCases} -// default: -// return false -// }` -// } - -// private static compileIndiscriminable( -// branches: readonly BranchNode[], -// ctx: CompilationContext -// ) { -// if (branches.length === 0) { -// return compileFailureResult("custom", "nothing", ctx) -// } -// if (branches.length === 1) { -// return branches[0].compile(ctx) -// } -// return branches -// .map( -// (branch) => `(() => { -// ${branch.compile(ctx)} -// return true -// })()` -// ) -// .join(" || ") -// } - -// private static compileDiscriminant( -// discriminant: Discriminant, -// ctx: CompilationContext -// ) { -// if (discriminant.isPureRootLiteral) { -// // TODO: ctx? -// return this.compileDiscriminatedLiteral(discriminant.cases) -// } -// let compiledPath = this.argName -// for (const segment of discriminant.path) { -// // we need to access the path as optional so we don't throw if it isn't present -// compiledPath += compilePropAccess(segment, true) -// } -// const condition = -// discriminant.kind === "domain" ? `typeof ${compiledPath}` : compiledPath -// let compiledCases = "" -// for (const k in discriminant.cases) { -// const caseCondition = k === "default" ? "default" : `case ${k}` -// const caseBranches = discriminant.cases[k] -// ctx.discriminants.push(discriminant) -// const caseChecks = isArray(caseBranches) -// ? this.compileIndiscriminable(caseBranches, ctx) -// : this.compileDiscriminant(caseBranches, ctx) -// ctx.discriminants.pop() -// compiledCases += `${caseCondition}: { -// ${caseChecks ? `${caseChecks}\n break` : "break"} -// }` -// } -// if (!discriminant.cases.default) { -// // TODO: error message for traversal -// compiledCases += `default: { -// return false -// }` -// } -// return `switch(${condition}) { -// ${compiledCases} -// }` -// } - export const intersectBranches = ( l: readonly UnionChildNode[], r: readonly UnionChildNode[], @@ -430,3 +539,86 @@ export const reduceBranches = ({ } return branches.filter((_, i) => uniquenessByIndex[i]) } + +export type CaseKey<kind extends DiscriminantKind = DiscriminantKind> = + DiscriminantKind extends kind ? string : DiscriminantKinds[kind] | "default" + +export type Discriminant<kind extends DiscriminantKind = DiscriminantKind> = { + path: string[] + kind: kind + cases: DiscriminatedCases<kind> +} + +export type DiscriminatedCases< + kind extends DiscriminantKind = DiscriminantKind +> = { + [caseKey in CaseKey<kind>]: BaseRoot | true +} + +type DiscriminantKey = `${SerializedPath}${DiscriminantKind}` + +type CasesBySpecifier = { + [k in DiscriminantKey]?: Record<string, BaseRoot[]> +} + +export type DiscriminantKinds = { + domain: Domain + unit: SerializedPrimitive +} + +const discriminantKinds: keySet<DiscriminantKind> = { + domain: 1, + unit: 1 +} + +export type DiscriminantKind = show<keyof DiscriminantKinds> + +const parseDiscriminantKey = (key: DiscriminantKey) => { + const lastPathIndex = key.lastIndexOf("]") + const parsedPath: string[] = JSON.parse(key.slice(0, lastPathIndex + 1)) + const parsedKind: DiscriminantKind = key.slice(lastPathIndex + 1) as never + return [parsedPath, parsedKind] as const +} + +export const pruneDiscriminant = ( + discriminantKind: DiscriminantKind, + path: TraversalPath, + branch: BaseRoot +): BaseRoot | null => + branch.transform( + (nodeKind, inner, ctx) => { + // if we've already checked a path at least as long as the current one, + // we don't need to revalidate that we're in an object + if ( + nodeKind === "domain" && + (inner as DomainInner).domain === "object" && + path.length > ctx.path.length + ) + return null + + // if the discriminant has already checked the domain at the current path + // (or an exact value, implying a domain), we don't need to recheck it + if ( + (discriminantKind === nodeKind || + (nodeKind === "domain" && ctx.path.length === path.length)) && + ctx.path.length === path.length && + ctx.path.every((segment, i) => segment === path[i]) + ) + return null + return inner + }, + { + shouldTransform: node => + node.children.length !== 0 || + node.kind === "domain" || + node.kind === "unit" + } + ) + +// // TODO: if deeply includes morphs? +// const writeUndiscriminableMorphUnionMessage = <path extends string>( +// path: path +// ) => +// `${ +// path === "/" ? "A" : `At ${path}, a` +// } union including one or more morphs must be discriminable` as const diff --git a/ark/schema/roots/unit.ts b/ark/schema/roots/unit.ts index 35878b9f3b..231b47e3c0 100644 --- a/ark/schema/roots/unit.ts +++ b/ark/schema/roots/unit.ts @@ -55,7 +55,17 @@ export const unitImplementation: nodeImplementationOf<UnitDeclaration> = intersections: { unit: (l, r) => Disjoint.from("unit", l, r), ...defineRightwardIntersections("unit", (l, r) => - r.allows(l.unit) ? l : Disjoint.from("assignability", l.unit, r) + r.allows(l.unit) ? l : ( + Disjoint.from( + "assignability", + l, + r.hasKind("intersection") ? + r.children.find( + rConstraint => !rConstraint.allows(l.unit as never) + )! + : r + ) + ) ) } }) diff --git a/ark/schema/scope.ts b/ark/schema/scope.ts index 172592d59a..e38e362580 100644 --- a/ark/schema/scope.ts +++ b/ark/schema/scope.ts @@ -2,6 +2,7 @@ import { CompiledFunction, DynamicBase, bound, + envHasCsp, flatMorph, hasDomain, isArray, @@ -135,7 +136,7 @@ export const defaultConfig: ResolvedArkConfig = Object.assign( implementation.defaults ]), { - jitless: false, + jitless: envHasCsp(), registerKeywords: false, prereducedAliases: false } satisfies Omit<ResolvedArkConfig, NodeKind> @@ -388,12 +389,11 @@ export class RawRootScope<$ extends RawRootResolutions = RawRootResolutions> if (this.resolved) { // this node was not part of the original scope, so compile an anonymous scope // including only its references - if (!this.resolvedConfig.jitless) - bindCompiledScope(node.contributesReferences) + if (!this.resolvedConfig.jitless) bindCompiledScope(node.references) } else { // we're still parsing the scope itself, so defer compilation but // add the node as a reference - Object.assign(this.referencesById, node.contributesReferencesById) + Object.assign(this.referencesById, node.referencesById) } return node as never @@ -699,8 +699,8 @@ export const bindCompiledScope = (references: readonly BaseNode[]): void => { } } -const compileScope = (references: readonly BaseNode[]) => { - return new CompiledFunction() +const compileScope = (references: readonly BaseNode[]) => + new CompiledFunction() .block("return", js => { references.forEach(node => { const allowsCompiler = new NodeCompiler("Allows").indent() @@ -719,4 +719,3 @@ const compileScope = (references: readonly BaseNode[]) => { [k: `${string}Apply`]: TraverseApply } >()() -} diff --git a/ark/schema/shared/compile.ts b/ark/schema/shared/compile.ts index d4787a0b16..16365f11f1 100644 --- a/ark/schema/shared/compile.ts +++ b/ark/schema/shared/compile.ts @@ -1,8 +1,6 @@ import { CompiledFunction } from "@arktype/util" -import type { Node } from "../kinds.js" import type { BaseNode } from "../node.js" -import type { Discriminant } from "../roots/discriminate.js" -import type { PrimitiveKind } from "./implement.js" +import type { Discriminant } from "../roots/union.js" import type { TraversalKind } from "./traversal.js" export interface InvokeOptions extends ReferenceOptions { @@ -76,39 +74,6 @@ export class NodeCompiler extends CompiledFunction<["data", "ctx"]> { : this.line(this.invoke(node, opts)) } - compilePrimitive(node: Node<PrimitiveKind>): this { - const pathString = this.path.join() - if ( - node.kind === "domain" && - node.domain === "object" && - this.discriminants.some(d => d.path.join().startsWith(pathString)) - ) { - // if we've already checked a path at least as long as the current one, - // we don't need to revalidate that we're in an object - return this - } - if ( - (node.kind === "domain" || node.kind === "unit") && - this.discriminants.some( - d => - d.path.join() === pathString && - (node.kind === "domain" ? - d.kind === "domain" || d.kind === "value" - : d.kind === "value") - ) - ) { - // if the discriminant has already checked the domain at the current path - // (or an exact value, implying a domain), we don't need to recheck it - return this - } - if (this.traversalKind === "Allows") - return this.return(node.compiledCondition) - - return this.if(node.compiledNegation, () => - this.line(`${this.ctx}.error(${node.compiledErrorContext})`) - ) - } - writeMethod(name: string): string { return `${name}(${this.argNames.join(", ")}){\n${this.body} }\n` } diff --git a/ark/schema/shared/disjoint.ts b/ark/schema/shared/disjoint.ts index d764ed614c..0b3525edc7 100644 --- a/ark/schema/shared/disjoint.ts +++ b/ark/schema/shared/disjoint.ts @@ -10,8 +10,9 @@ import { type entryOf } from "@arktype/util" import type { Node } from "../kinds.js" +import type { BaseNode } from "../node.js" import type { BaseRoot } from "../roots/root.js" -import type { BoundKind, PrimitiveKind } from "./implement.js" +import type { BoundKind } from "./implement.js" import { hasArkKind } from "./utils.js" type DisjointKinds = { @@ -35,15 +36,11 @@ type DisjointKinds = { l: Node<BoundKind> r: Node<BoundKind> } - assignability?: - | { - l: unknown - r: Node<PrimitiveKind> - } - | { - l: Node<PrimitiveKind> - r: unknown - } + // exactly one of l or r should be a UnitNode + assignability?: { + l: BaseNode + r: BaseNode + } union?: { l: readonly BaseRoot[] r: readonly BaseRoot[] diff --git a/ark/schema/shared/intersections.ts b/ark/schema/shared/intersections.ts index 8d2ce4ee43..804c02bc59 100644 --- a/ark/schema/shared/intersections.ts +++ b/ark/schema/shared/intersections.ts @@ -166,12 +166,17 @@ export const pipeFromMorph = ( to: BaseRoot, ctx: IntersectionContext ): MorphNode | Disjoint => { - const out = from?.out ? intersectNodes(from.out, to, ctx) : to - if (out instanceof Disjoint) return out + const morphs = [...from.morphs] + if (from.validatedOut) { + // still piped from context, so allows appending additional morphs + const outIntersection = intersectNodes(from.validatedOut, to, ctx) + if (outIntersection instanceof Disjoint) return outIntersection + morphs[morphs.length - 1] = outIntersection + } else morphs.push(to) + return ctx.$.node("morph", { - morphs: from.morphs, - in: from.in, - out + morphs, + in: from.in }) } @@ -184,7 +189,6 @@ export const pipeToMorph = ( if (result instanceof Disjoint) return result return ctx.$.node("morph", { morphs: to.morphs, - in: result, - out: to.out + in: result }) } diff --git a/ark/schema/shared/traversal.ts b/ark/schema/shared/traversal.ts index 678f3cfa1f..4e93171c94 100644 --- a/ark/schema/shared/traversal.ts +++ b/ark/schema/shared/traversal.ts @@ -13,7 +13,6 @@ import type { TraversalPath } from "./utils.js" export type QueuedMorphs = { path: TraversalPath morphs: array<Morph> - to?: TraverseApply } export type BranchTraversalContext = { @@ -21,10 +20,6 @@ export type BranchTraversalContext = { queuedMorphs: QueuedMorphs[] } -export type QueueMorphOptions = { - outValidator?: TraverseApply -} - export class TraversalContext { path: TraversalPath = [] queuedMorphs: QueuedMorphs[] = [] @@ -42,12 +37,11 @@ export class TraversalContext { return this.branches.at(-1) } - queueMorphs(morphs: array<Morph>, opts?: QueueMorphOptions): void { + queueMorphs(morphs: array<Morph>): void { const input: QueuedMorphs = { path: [...this.path], morphs } - if (opts?.outValidator) input.to = opts?.outValidator this.currentBranch?.queuedMorphs.push(input) ?? this.queuedMorphs.push(input) } @@ -58,46 +52,35 @@ export class TraversalContext { let out: any = this.root if (this.queuedMorphs.length) { for (let i = 0; i < this.queuedMorphs.length; i++) { - const { path, morphs, to } = this.queuedMorphs[i] - if (path.length === 0) { - this.path = [] - // if the morph applies to the root, just assign to it directly - for (const morph of morphs) { - const result = morph(out, this) - if (result instanceof ArkErrors) return result - if (this.hasError()) return this.errors - if (result instanceof ArkError) { - // if an ArkTypeError was returned but wasn't added to these - // errors, add it then return - this.error(result) - return this.errors - } - out = result - } - } else { + const { path, morphs } = this.queuedMorphs[i] + + const key = path.at(-1) + + let parent: any + + if (key !== undefined) { // find the object on which the key to be morphed exists - let parent = out + parent = out for (let pathIndex = 0; pathIndex < path.length - 1; pathIndex++) parent = parent[path[pathIndex]] + } - // apply the morph function and assign the result to the corresponding property - const key = path.at(-1)! - this.path = path - for (const morph of morphs) { - const result = morph(parent[key], this) - if (result instanceof ArkErrors) return result - if (this.hasError()) return this.errors - if (result instanceof ArkError) { - this.error(result) - return this.errors - } - parent[key] = result + this.path = path + for (const morph of morphs) { + const result = morph(parent === undefined ? out : parent[key!], this) + if (result instanceof ArkErrors) return result + if (this.hasError()) return this.errors + if (result instanceof ArkError) { + // if an ArkError was returned but wasn't added to these + // errors, add it then return + this.error(result) + return this.errors } - } - if (to) { - const toCtx = new TraversalContext(out, this.config) - to(out, toCtx) - return toCtx.finalize() + + // apply the morph function and assign the result to the + // corresponding property, or to root if path is empty + if (parent === undefined) out = result + else parent[key!] = result } } } diff --git a/ark/schema/structure/index.ts b/ark/schema/structure/index.ts index ff2169f535..1b07510840 100644 --- a/ark/schema/structure/index.ts +++ b/ark/schema/structure/index.ts @@ -5,6 +5,10 @@ import { } from "@arktype/util" import { BaseConstraint } from "../constraint.js" import type { Node, RootSchema } from "../kinds.js" +import type { + DeepNodeTransformation, + DeepNodeTransformationContext +} from "../node.js" import type { BaseRoot } from "../roots/root.js" import type { UnitNode } from "../roots/unit.js" import type { BaseMeta, declareNode } from "../shared/declare.js" @@ -129,6 +133,16 @@ export class IndexNode extends BaseConstraint<IndexDeclaration> { } }) + protected override _transform( + mapper: DeepNodeTransformation, + ctx: DeepNodeTransformationContext + ) { + ctx.path.push(this.signature) + const result = super._transform(mapper, ctx) + ctx.path.pop() + return result + } + compile(): void { // this is currently handled by StructureNode } diff --git a/ark/schema/structure/prop.ts b/ark/schema/structure/prop.ts index 77eae2f390..5e70f942c6 100644 --- a/ark/schema/structure/prop.ts +++ b/ark/schema/structure/prop.ts @@ -8,6 +8,10 @@ import { } from "@arktype/util" import { BaseConstraint } from "../constraint.js" import type { Node, RootSchema } from "../kinds.js" +import type { + DeepNodeTransformation, + DeepNodeTransformationContext +} from "../node.js" import type { Morph } from "../roots/morph.js" import type { BaseRoot } from "../roots/root.js" import type { NodeCompiler } from "../shared/compile.js" @@ -92,6 +96,16 @@ export abstract class BaseProp< compiledKey: string = typeof this.key === "string" ? this.key : this.serializedKey + protected override _transform( + mapper: DeepNodeTransformation, + ctx: DeepNodeTransformationContext + ) { + ctx.path.push(this.key) + const result = super._transform(mapper, ctx) + ctx.path.pop() + return result + } + private defaultValueMorphs: Morph[] = [ data => { data[this.key] = (this as OptionalNode).default diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 7474e1bdec..61422ec9fe 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -8,6 +8,10 @@ import { } from "@arktype/util" import { BaseConstraint } from "../constraint.js" import type { MutableInner, RootSchema } from "../kinds.js" +import type { + DeepNodeTransformation, + DeepNodeTransformationContext +} from "../node.js" import type { MaxLengthNode } from "../refinements/maxLength.js" import type { MinLengthNode } from "../refinements/minLength.js" import type { BaseRoot } from "../roots/root.js" @@ -309,6 +313,16 @@ export class SequenceNode extends BaseConstraint<SequenceDeclaration> { if (js.traversalKind === "Allows") js.return(true) } + protected override _transform( + mapper: DeepNodeTransformation, + ctx: DeepNodeTransformationContext + ) { + ctx.path.push(this.$.keywords.nonNegativeIntegerString.raw) + const result = super._transform(mapper, ctx) + ctx.path.pop() + return result + } + tuple: SequenceTuple = sequenceInnerToTuple(this.inner) // this depends on tuple so needs to come after it expression: string = this.description diff --git a/ark/type/__tests__/discrimination.test.ts b/ark/type/__tests__/discrimination.test.ts index 416a75ad69..2ac60e80ec 100644 --- a/ark/type/__tests__/discrimination.test.ts +++ b/ark/type/__tests__/discrimination.test.ts @@ -1,50 +1,94 @@ -// import { attest } from "@arktype/attest" -// import { scope, type } from "arktype" +import { attest, contextualize } from "@arktype/attest" +import { scope, type } from "arktype" -describe("discrimination", () => { - // it("2 literal branches", () => { - // // should not use a switch with <=2 branches to avoid visual clutter - // const t = type("'a'|'b'") - // attest(t.json).snap({ unit: "a" }) - // attest(t.allows("a")).equals(true) - // attest(t.allows("b")).equals(true) - // attest(t.allows("c")).equals(false) - // }) - // it(">2 literal branches", () => { - // const t = type("'a'|'b'|'c'") - // attest(t.json).snap({ unit: "a" }) - // attest(t.allows("a")).equals(true) - // attest(t.allows("b")).equals(true) - // attest(t.allows("c")).equals(true) - // attest(t.allows("d")).equals(false) - // }) - // const getPlaces = () => - // scope({ - // rainForest: { - // climate: "'wet'", - // color: "'green'", - // isRainForest: "true" - // }, - // desert: { climate: "'dry'", color: "'brown'", isDesert: "true" }, - // sky: { climate: "'dry'", color: "'blue'", isSky: "true" }, - // ocean: { climate: "'wet'", color: "'blue'", isOcean: "true" } - // }) - // it("nested", () => { - // const t = getPlaces().type("ocean|sky|rainForest|desert") - // attest(t.json).snap() - // }) - // it("undiscriminable", () => { - // const t = getPlaces().type([ - // "ocean", - // "|", - // { - // climate: "'wet'", - // color: "'blue'", - // indistinguishableFrom: "ocean" - // } - // ]) - // }) - // it("doesn't discriminate optional key", () => { +contextualize(() => { + it("2 literal branches", () => { + // should not use a switch with <=2 branches to avoid visual clutter + const t = type("'a'|'b'") + attest(t.json).snap([{ unit: "a" }, { unit: "b" }]) + attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ + kind: "unit", + path: [], + cases: { '"a"': true, '"b"': true } + }) + attest(t.allows("a")).equals(true) + attest(t.allows("b")).equals(true) + attest(t.allows("c")).equals(false) + }) + + it(">2 literal branches", () => { + const t = type("'a'|'b'|'c'") + attest(t.json).snap([{ unit: "a" }, { unit: "b" }, { unit: "c" }]) + attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ + kind: "unit", + path: [], + cases: { '"a"': true, '"b"': true, '"c"': true } + }) + attest(t.allows("a")).equals(true) + attest(t.allows("b")).equals(true) + attest(t.allows("c")).equals(true) + attest(t.allows("d")).equals(false) + }) + + const getPlaces = () => + scope({ + rainForest: { + climate: "'wet'", + color: "'green'", + isRainForest: "true" + }, + desert: { climate: "'dry'", color: "'brown'", isDesert: "true" }, + sky: { climate: "'dry'", color: "'blue'", isSky: "true" }, + ocean: { climate: "'wet'", color: "'blue'", isOcean: "true" } + }) + + it("nested", () => { + const $ = getPlaces() + const t = $.type("ocean|sky|rainForest|desert") + attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ + kind: "unit", + path: ["color"], + cases: { + '"blue"': { + kind: "unit", + path: ["climate"], + cases: { + '"dry"': { required: [{ key: "isSky", value: { unit: true } }] }, + '"wet"': { required: [{ key: "isOcean", value: { unit: true } }] } + } + }, + '"brown"': { + required: [ + { key: "climate", value: { unit: "dry" } }, + { key: "isDesert", value: { unit: true } } + ] + }, + '"green"': { + required: [ + { key: "climate", value: { unit: "wet" } }, + { key: "isRainForest", value: { unit: true } } + ] + } + } + }) + }) + + it("indiscriminable", () => { + const t = getPlaces().type([ + "ocean", + "|", + { + climate: "'wet'", + color: "'blue'", + indistinguishableFrom: "ocean" + } + ]) + + attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) + }) + + // https://github.com/arktypeio/arktype/issues/960 + // it("discriminate optional key", () => { // const t = type({ // direction: "'forward' | 'backward'", // "operator?": "'by'" @@ -52,23 +96,77 @@ describe("discrimination", () => { // duration: "'s' | 'min' | 'h'", // operator: "'to'" // }) - // attest(t.hasKind("union") && t.discriminant).equals(null) - // }) - // it("default case", () => { - // const t = getPlaces().type([ - // "ocean|rainForest", - // "|", - // { temperature: "'hot'" } - // ]) - // }) - // it("discriminable default", () => { - // const t = getPlaces().type([ - // { temperature: "'cold'" }, - // "|", - // ["ocean|rainForest", "|", { temperature: "'hot'" }] - // ]) - // }) - // it("won't discriminate between possibly empty arrays", () => { - // const t = type("string[]|boolean[]") + + // attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) // }) + + it("default case", () => { + const t = getPlaces().type([ + "ocean|rainForest", + "|", + { temperature: "'hot'" } + ]) + + attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ + kind: "unit", + path: ["color"], + cases: { + '"blue"': { + required: [ + { key: "climate", value: { unit: "wet" } }, + { key: "isOcean", value: { unit: true } } + ] + }, + '"green"': { + required: [ + { key: "climate", value: { unit: "wet" } }, + { key: "isRainForest", value: { unit: true } } + ] + }, + default: { + required: [{ key: "temperature", value: { unit: "hot" } }], + domain: "object" + } + } + }) + }) + + it("discriminable default", () => { + const t = getPlaces().type([ + { temperature: "'cold'" }, + "|", + ["ocean|rainForest", "|", { temperature: "'hot'" }] + ]) + attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ + kind: "unit", + path: ["temperature"], + cases: { + '"cold"': true, + '"hot"': true, + default: { + kind: "unit", + path: ["color"], + cases: { + '"blue"': { + required: [ + { key: "climate", value: { unit: "wet" } }, + { key: "isOcean", value: { unit: true } } + ] + }, + '"green"': { + required: [ + { key: "climate", value: { unit: "wet" } }, + { key: "isRainForest", value: { unit: true } } + ] + } + } + } + } + }) + }) + + it("won't discriminate between possibly empty arrays", () => { + const t = type("string[]|boolean[]") + attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) + }) }) diff --git a/ark/type/__tests__/divisor.test.ts b/ark/type/__tests__/divisor.test.ts index a22dcd6233..2eb6f9e145 100644 --- a/ark/type/__tests__/divisor.test.ts +++ b/ark/type/__tests__/divisor.test.ts @@ -107,7 +107,7 @@ contextualize( it("invalid literal", () => { attest(() => type("number%3&8")).throws.snap( - "ParseError: Intersection of 8 and number & % 3 results in an unsatisfiable type" + "ParseError: Intersection of 8 and % 3 results in an unsatisfiable type" ) }) } diff --git a/ark/type/__tests__/expressions.test.ts b/ark/type/__tests__/expressions.test.ts index 1bcf65ecf4..af89ade278 100644 --- a/ark/type/__tests__/expressions.test.ts +++ b/ark/type/__tests__/expressions.test.ts @@ -51,6 +51,7 @@ contextualize( "email", "uuid", "semver", + "ip", "Record", "instanceof", "===", @@ -90,7 +91,6 @@ contextualize( "keyof", "parse", "void", - "[]", "url", "alpha", "alphanumeric", @@ -100,7 +100,9 @@ contextualize( "email", "uuid", "semver", + "ip", "Record", + "[]", "|", ":", "=>", diff --git a/ark/type/__tests__/keywords.test.ts b/ark/type/__tests__/keywords.test.ts index ed4a0ecc4b..df4dcfd886 100644 --- a/ark/type/__tests__/keywords.test.ts +++ b/ark/type/__tests__/keywords.test.ts @@ -51,10 +51,6 @@ contextualize( const expected = rawRoot([{ unit: false }, { unit: true }]) // should be simplified to simple checks for true and false literals attest(boolean.json).equals(expected.json) - // TODO: - // attest(boolean.json).snap(`if( $arkRoot !== false && $arkRoot !== true) { - // return false - // }`) }) it("never", () => { @@ -84,130 +80,143 @@ contextualize( //should be treated as undefined at runtime attest(t.json).equals(expected.json) }) + }, + "validation", + () => { + it("integer", () => { + const integer = type("integer") + attest(integer(123)).equals(123) + attest(integer("123").toString()).snap("must be a number (was string)") + attest(integer(12.12).toString()).snap("must be an integer (was 12.12)") + }) + it("alpha", () => { + const alpha = type("alpha") + attest(alpha("user")).snap("user") + attest(alpha("user123").toString()).equals( + 'must be only letters (was "user123")' + ) + }) + it("alphanumeric", () => { + const alphanumeric = type("alphanumeric") + attest(alphanumeric("user123")).snap("user123") + attest(alphanumeric("user")).snap("user") + attest(alphanumeric("123")).snap("123") + attest(alphanumeric("abc@123").toString()).equals( + 'must be only letters and digits (was "abc@123")' + ) + }) + it("lowercase", () => { + const lowercase = type("lowercase") + attest(lowercase("var")).snap("var") + attest(lowercase("newVar").toString()).equals( + 'must be only lowercase letters (was "newVar")' + ) + }) + it("uppercase", () => { + const uppercase = type("uppercase") + attest(uppercase("VAR")).snap("VAR") + attest(uppercase("CONST_VAR").toString()).equals( + 'must be only uppercase letters (was "CONST_VAR")' + ) + attest(uppercase("myVar").toString()).equals( + 'must be only uppercase letters (was "myVar")' + ) + }) + it("email", () => { + const email = type("email") + attest(email("shawn@mail.com")).snap("shawn@mail.com") + attest(email("shawn@email").toString()).equals( + 'must be a valid email (was "shawn@email")' + ) + }) + it("uuid", () => { + const uuid = type("uuid") + attest(uuid("f70b8242-dd57-4e6b-b0b7-649d997140a0")).equals( + "f70b8242-dd57-4e6b-b0b7-649d997140a0" + ) + attest(uuid("1234").toString()).equals( + 'must be a valid UUID (was "1234")' + ) + }) + + it("credit card", () => { + const validCC = "5489582921773376" + attest(ark.creditCard(validCC)).equals(validCC) + // Regex validation + attest(ark.creditCard("0".repeat(16)).toString()).equals( + 'must be a valid credit card number (was "0000000000000000")' + ) + // Luhn validation + attest(ark.creditCard(validCC.slice(0, -1) + "0").toString()).equals( + 'must be a valid credit card number (was "5489582921773370")' + ) + }) + it("semver", () => { + attest(ark.semver("1.0.0")).snap("1.0.0") + attest(ark.semver("-1.0.0").toString()).equals( + 'must be a valid semantic version (see https://semver.org/) (was "-1.0.0")' + ) + }) + + it("ip", () => { + const ip = type("ip") + + // valid IPv4 address + attest(ip("192.168.1.1")).snap("192.168.1.1") + // valid IPv6 address + attest(ip("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).snap( + "2001:0db8:85a3:0000:0000:8a2e:0370:7334" + ) + + attest(ip("192.168.1.256").toString()).snap( + 'must be a valid IPv4 address or a valid IPv6 address (was "192.168.1.256")' + ) + attest(ip("2001:0db8:85a3:0000:0000:8a2e:0370:733g").toString()).snap( + 'must be a valid IPv4 address or a valid IPv6 address (was "2001:0db8:85a3:0000:0000:8a2e:0370:733g")' + ) + }) + }, + "parse", + () => { + it("json", () => { + const parseJson = type("parse.json") + attest(parseJson('{"a": "hello"}')).snap({ a: "hello" }) + attest(parseJson(123).toString()).snap("must be a string (was number)") + attest(parseJson("foo").toString()).snap( + 'must be a valid JSON string (was "foo")' + ) + }) + it("number", () => { + const parseNum = type("parse.number") + attest(parseNum("5")).equals(5) + attest(parseNum("5.5")).equals(5.5) + attest(parseNum("five").toString()).equals( + 'must be a well-formed numeric string (was "five")' + ) + }) + it("integer", () => { + const parseInt = type("parse.integer") + attest(parseInt("5")).equals(5) + attest(parseInt("5.5").toString()).equals( + 'must be a well-formed integer string (was "5.5")' + ) + attest(parseInt("five").toString()).equals( + 'must be a well-formed integer string (was "five")' + ) + attest(parseInt(5).toString()).snap("must be a string (was number)") + attest(parseInt("9007199254740992").toString()).equals( + 'must be an integer in the range Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER (was "9007199254740992")' + ) + }) + it("date", () => { + const parseDate = type("parse.date") + attest(parseDate("5/21/1993").toString()).snap( + "Fri May 21 1993 00:00:00 GMT-0400 (Eastern Daylight Time)" + ) + attest(parseDate("foo").toString()).equals( + 'must be a valid date (was "foo")' + ) + attest(parseDate(5).toString()).snap("must be a string (was number)") + }) } ) - -// describe("validation", () => { -// it("integer", () => { -// const integer = type("integer") -// attest(integer(123)).equals(123) -// attest(integer("123").toString()).equals( -// "must be a number (was string)" -// ) -// attest(integer(12.12).toString()).equals( -// "must be an integer (was 12.12)" -// ) -// }) -// it("alpha", () => { -// const alpha = type("alpha") -// attest(alpha("user")).equals("user") -// attest(alpha("user123").toString()).equals( -// "must be only letters (was 'user123')" -// ) -// }) -// it("alphanumeric", () => { -// const alphanumeric = type("alphanumeric") -// attest(alphanumeric("user123")).equals("user123") -// attest(alphanumeric("user")).equals("user") -// attest(alphanumeric("123")).equals("123") -// attest(alphanumeric("abc@123").toString()).equals( -// "must be only letters and digits (was 'abc@123')" -// ) -// }) -// it("lowercase", () => { -// const lowercase = type("lowercase") -// attest(lowercase("var")).equals("var") -// attest(lowercase("newVar").toString()).equals( -// "must be only lowercase letters (was 'newVar')" -// ) -// }) -// it("uppercase", () => { -// const uppercase = type("uppercase") -// attest(uppercase("VAR")).equals("VAR") -// attest(uppercase("CONST_VAR").toString()).equals( -// "must be only uppercase letters (was 'CONST_VAR')" -// ) -// attest(uppercase("myVar").toString()).equals( -// "must be only uppercase letters (was 'myVar')" -// ) -// }) -// it("email", () => { -// const email = type("email") -// attest(email("shawn@mail.com")).equals("shawn@mail.com") -// attest(email("shawn@email").toString()).equals( -// "must be a valid email (was 'shawn@email')" -// ) -// }) -// it("uuid", () => { -// const uuid = type("uuid") -// attest(uuid("f70b8242-dd57-4e6b-b0b7-649d997140a0")).equals( -// "f70b8242-dd57-4e6b-b0b7-649d997140a0" -// ) -// attest(uuid("1234").toString()).equals( -// "must be a valid UUID (was '1234')" -// ) -// }) -// it("parsedNumber", () => { -// const parsedNumber = type("parsedNumber") -// attest(parsedNumber("5")).equals(5) -// attest(parsedNumber("5.5")).equals(5.5) -// attest(parsedNumber("five").toString()).equals( -// "must be a well-formed numeric string (was 'five')" -// ) -// }) -// it("parsedInteger", () => { -// const parsedInteger = type("parsedInteger") -// attest(parsedInteger("5")).equals(5) -// attest(parsedInteger("5.5").toString()).equals( -// "must be a well-formed integer string (was '5.5')" -// ) -// attest(parsedInteger("five").toString()).equals( -// "must be a well-formed integer string (was 'five')" -// ) -// attest(parsedInteger(5).toString()).equals( -// "must be a string (was number)" -// ) -// attest(parsedInteger("9007199254740992").toString()).equals( -// "must be an integer in the range Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER (was '9007199254740992')" -// ) -// }) -// it("parsedDate", () => { -// const parsedDate = type("parsedDate") -// attest(parsedDate("5/21/1993").out?.toDateString()).equals( -// "Fri May 21 1993" -// ) -// attest(parsedDate("foo").toString()).equals( -// "must be a valid date (was 'foo')" -// ) -// attest(parsedDate(5).toString()).equals( -// "must be a string (was number)" -// ) -// }) -// it("json", () => { -// const json = type("json") -// attest(json('{"a": "hello"}')).equals({ a: "hello" }) -// attest(json(123).toString()).equals( -// "must be a JSON-parsable string (was number)" -// ) -// }) -// it("credit card", () => { -// const validCC = "5489582921773376" -// attest(ark.creditCard(validCC)).equals(validCC) -// // Regex validation -// attest(ark.creditCard("0".repeat(16)).toString()).equals( -// "must be a valid credit card number (was '0000000000000000')" -// ) -// // Luhn validation -// attest( -// ark.creditCard(validCC.slice(0, -1) + "0").toString() -// ).equals( -// "must be a valid credit card number (was '5489582921773370')" -// ) -// }) -// it("semver", () => { -// attest(ark.semver("1.0.0")).equals("1.0.0") -// attest(ark.semver("-1.0.0").toString()).equals( -// "must be a valid semantic version (see https://semver.org/) (was '-1.0.0')" -// ) -// }) -// }) diff --git a/ark/type/__tests__/match.test.ts b/ark/type/__tests__/match.test.ts index 6c179acb76..a9cb5471b4 100644 --- a/ark/type/__tests__/match.test.ts +++ b/ark/type/__tests__/match.test.ts @@ -3,9 +3,9 @@ // it("cases only", () => { // const sizeOf = match({ -// "string|Array": (v) => v.length, -// number: (v) => v, -// bigint: (v) => v +// "string|Array": v => v.length, +// number: v => v, +// bigint: v => v // }).orThrow() // attest<number>(sizeOf("abc")).equals(3) @@ -14,8 +14,8 @@ // }) // it("properly infers types of inputs/outputs", () => { -// const matcher = match({ string: (s) => s, number: (n) => n }) -// .when("boolean", (b) => b) +// const matcher = match({ string: s => s, number: n => n }) +// .when("boolean", b => b) // .orThrow() // // properly infers the type of the output based on the input @@ -28,19 +28,19 @@ // }) // it("`.when` errors on redundant cases", () => { -// const matcher = match().when("string", (s) => s) +// const matcher = match().when("string", s => s) // // @ts-expect-error -// attest(() => matcher.when("string", (s) => s)).throwsAndHasTypeError( +// attest(() => matcher.when("string", s => s)).throwsAndHasTypeError( // "This branch is redundant and will never be reached" // TODO: rewrite error message // ) // }) // it("errors on cases redundant to a previous `cases` block", () => { -// const matcher = match({ string: (s) => s }) +// const matcher = match({ string: s => s }) // // @ts-expect-error -// attest(() => matcher.cases({ string: (s) => s })).throwsAndHasTypeError( +// attest(() => matcher.cases({ string: s => s })).throwsAndHasTypeError( // "This branch is redundant and will never be reached" // ) // }) @@ -64,7 +64,7 @@ // describe('"finalizations"', () => { // it(".orThrow()", () => { // const matcher = match() -// .when("string", (s) => s) +// .when("string", s => s) // .orThrow() // // properly returns the `never` type and throws given a guaranteed-to-be-invalid input @@ -108,7 +108,7 @@ // // }) // it("errors when attempting to `.finalize()` a non-exhaustive matcher", () => { -// const matcher = match().when("string", (s) => s) +// const matcher = match().when("string", s => s) // // @ts-expect-error // attest(() => matcher.finalize()).throwsAndHasTypeError( @@ -118,7 +118,7 @@ // it("considers `unknown` exhaustive", () => { // const matcher = match() -// .when("unknown", (x) => x) +// .when("unknown", x => x) // .finalize() // attest(matcher(4)).equals(4) @@ -129,8 +129,8 @@ // it("does not accept invalid inputs at a type-level", () => { // const matcher = match // .only<string | number>() -// .when("string", (s) => s) -// .when("number", (n) => n) +// .when("string", s => s) +// .when("number", n => n) // .finalize() // // @ts-expect-error @@ -140,7 +140,7 @@ // }) // it("errors when attempting to `.finalize()` a non-exhaustive matcher", () => { -// const matcher = match.only<string | number>().when("string", (s) => s) +// const matcher = match.only<string | number>().when("string", s => s) // // @ts-expect-error // attest(() => matcher.finalize()).throwsAndHasTypeError( @@ -148,11 +148,11 @@ // ) // }) -// it("allows finalizing exhaustive matchers", (_) => { +// it("allows finalizing exhaustive matchers", _ => { // const matcher = match // .only<string | number>() -// .when("string", (s) => s) -// .when("number", (n) => n) +// .when("string", s => s) +// .when("number", n => n) // .finalize() // attest<string>(matcher("abc")).equals("abc") @@ -164,8 +164,8 @@ // it("infers the parameter to chained .default as the remaining cases", () => { // const matcher = match // .only<string | number | boolean>() -// .when("string", (s) => s) -// .default((n) => { +// .when("string", s => s) +// .default(n => { // attest<number | boolean>(n) // return n // }) @@ -176,8 +176,8 @@ // it("infers the parameter to in-cases .default", () => { // const matcher = match.only<string | number | boolean>().cases({ -// string: (s) => s, -// default: (n) => { +// string: s => s, +// default: n => { // // TS doesn't understand sequentiality in cases, so it's inferred as the in-type // attest<string | number | boolean>(n) // return n @@ -191,7 +191,7 @@ // it("returns `never` on only the specific cases handled by `.orThrow`", () => { // const matcher = match // .only<string | number>() -// .when("string", (s) => s) +// .when("string", s => s) // .orThrow() // attest<never>(matcher(4)) @@ -203,12 +203,12 @@ // const matcher = threeSixtyNoScope // .match({ -// three: (three) => { +// three: three => { // attest<3>(three) // return 3 // } // }) -// .when("sixty", (sixty) => { +// .when("sixty", sixty => { // attest<60>(sixty) // return 60 // }) @@ -221,14 +221,14 @@ // it("properly propagates errors from invalid type definitions in `when`", () => { // // @ts-expect-error -// attest(() => match().when("strong", (s) => s)).type.errors( +// attest(() => match().when("strong", s => s)).type.errors( // "'strong' is unresolvable" // ) // }) // it("properly propagates errors from invalid type definitions in `cases`", () => { // // @ts-expect-error -// attest(() => match({ strong: (s) => s })).type.errors( +// attest(() => match({ strong: s => s })).type.errors( // "'strong' is unresolvable" // ) // }) diff --git a/ark/type/__tests__/pipe.test.ts b/ark/type/__tests__/pipe.test.ts index 515b644fba..49c4212b0a 100644 --- a/ark/type/__tests__/pipe.test.ts +++ b/ark/type/__tests__/pipe.test.ts @@ -216,6 +216,7 @@ contextualize(() => { it("union with output", () => { const t = type("number|parse.number") attest<number>(t.infer) + attest<string | number>(t.inferIn) }) it("deep union", () => { diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index 77019a6c7a..ea5ba55b3e 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -1,4 +1,6 @@ import { attest, contextualize } from "@arktype/attest" +import type { AtLeastLength, AtMostLength, Out, string } from "@arktype/schema" +import { registeredReference } from "@arktype/util" import { scope, type, type Type } from "arktype" contextualize(() => { @@ -246,4 +248,120 @@ nospace must be matched by ^\\S*$ (was "One space")`) ] }) }) + + // https://github.com/arktypeio/arktype/issues/947 + it("chained inline type expression inference", () => { + const a = type({ + action: "'a' | 'b'" + }).or({ + action: "'c'" + }) + + const referenced = type({ + someField: "string" + }).and(a) + + attest< + | { + someField: string + action: "a" | "b" + } + | { + someField: string + action: "c" + } + >(referenced.infer) + + const inlined = type({ + someField: "string" + }).and( + type({ + action: "'a' | 'b'" + }).or({ + action: "'c'" + }) + ) + + attest<typeof referenced>(inlined) + }) + + // https://discord.com/channels/957797212103016458/1242116299547476100 + it("infers morphs at nested paths", () => { + const parseBigint = type("string", "=>", (s, ctx) => { + try { + return BigInt(s) + } catch { + return ctx.error("a valid number") + } + }) + + const Test = type({ + group: { + nested: { + value: parseBigint + } + } + }) + + const out = Test({ group: { nested: { value: "5" } } }) + attest<bigint, typeof Test.infer.group.nested.value>() + attest(out).equals({ group: { nested: { value: 5n } } }) + }) + + // https://discord.com/channels/957797212103016458/957804102685982740/1242221022380556400 + it("nested pipe to validated output", () => { + const trimString = (s: string) => s.trim() + + const trimStringReference = registeredReference(trimString) + + const validatedTrimString = type("string").pipe( + trimString, + type("1<=string<=3") + ) + + const CreatePatientInput = type({ + "patient_id?": "string|null", + "first_name?": validatedTrimString.or("null"), + "middle_name?": "string|null", + "last_name?": "string|null" + }) + + attest< + | ((In: string) => Out<string.is<AtLeastLength<1> & AtMostLength<3>>>) + | null + | undefined, + typeof CreatePatientInput.t.first_name + >() + + attest(CreatePatientInput.json).snap({ + optional: [ + { + key: "first_name", + value: [ + { + in: "string", + morphs: [ + trimStringReference, + { domain: "string", maxLength: 3, minLength: 1 } + ] + }, + { unit: null } + ] + }, + { key: "last_name", value: ["string", { unit: null }] }, + { key: "middle_name", value: ["string", { unit: null }] }, + { key: "patient_id", value: ["string", { unit: null }] } + ], + domain: "object" + }) + attest(CreatePatientInput({ first_name: " Bob " })).equals({ + first_name: "Bob" + }) + attest(CreatePatientInput({ first_name: " John " }).toString()).snap( + "first_name must be at most length 3 (was 4)" + ) + attest(CreatePatientInput({ first_name: 5 }).toString()).snap( + "first_name must be a string or null (was 5)" + ) + }) }) diff --git a/ark/type/__tests__/scope.test.ts b/ark/type/__tests__/scope.test.ts index 5f7659ba0b..7fc10fdcf2 100644 --- a/ark/type/__tests__/scope.test.ts +++ b/ark/type/__tests__/scope.test.ts @@ -313,7 +313,7 @@ dependencies[1].contributors[0].email must be a valid email (was "ssalbdivad")`) attest(types.a(valid)).equals(valid) attest(types.a({ b: { a: { b: { a: 4 } } } }).toString()).snap( - 'b.a.b.a must be an object or 3 (was number, 4) or b.a must be 3 (was {"b":{"a":4}})' + 'b.a.b.a must be an object or 3 (was 4) or b.a must be 3 (was {"b":{"a":4}})' ) attest(types.b.infer).type.toString.snap("{ a: 3 | { b: ...; }; }") diff --git a/ark/type/__tests__/traverse.test.ts b/ark/type/__tests__/traverse.test.ts index d61334a834..1447080704 100644 --- a/ark/type/__tests__/traverse.test.ts +++ b/ark/type/__tests__/traverse.test.ts @@ -60,7 +60,8 @@ contextualize(() => { const t = type("string|number[]") attest(t([1])).snap([1]) attest(t("hello")).snap("hello") - attest(t(2).toString()).snap("must be a string or an array (was number)") + attest(t(2).toString()).snap("must be a string or an object (was number)") + attest(t({}).toString()).snap("must be an array (was object)") }) it("tuple length", () => { @@ -84,6 +85,13 @@ contextualize(() => { ) }) + it("common errors collapse", () => { + const t = type({ base: "1", a: "1" }, "|", { base: "1", b: "1" }) + attest(t({ base: 1, a: 1 })).snap({ base: 1, a: 1 }) + attest(t({ base: 1, b: 1 })).snap({ base: 1, b: 1 }) + attest(t({ a: 1, b: 1 }).toString()).snap("base must be 1 (was missing)") + }) + it("branches at path", () => { const t = type({ key: [{ a: "string" }, "|", { b: "boolean" }] }) attest(t({ key: { a: "ok" } })).snap({ key: { a: "ok" } }) @@ -98,29 +106,28 @@ contextualize(() => { attest(t({ a: "ok" })).snap({ a: "ok" }) attest(t({ a: 5 })).snap({ a: 5 }) // value isn't present - attest(t({}).toString()).snap( - "a must be a number, a string or null (was missing)" - ) + attest(t({}).toString()).snap("a must be null (was missing)") // unsatisfying value - attest(t({ a: false }).toString()).snap( - "a must be a number, a string or null (was false)" - ) + attest(t({ a: false }).toString()).snap("a must be null (was false)") }) - it("multiple switch", () => { - const types = scope({ - a: { foo: "string" }, - b: { foo: "number" }, - c: { foo: "Function" }, - d: "a|b|c" - }).export() - attest(types.d({}).toString()).snap( - "foo must be a function, a number or a string (was missing)" - ) - attest(types.d({ foo: null }).toString()).snap( - "foo must be a function, a number or a string (was null)" - ) - }) + // TODO: https://github.com/arktypeio/arktype/issues/962 + // it("multiple switch", () => { + // const types = scope({ + // a: { foo: "string" }, + // b: { foo: "number" }, + // c: { foo: "Function" }, + // d: "a|b|c" + // }).export() + // // attest(types.d({}).toString()).snap( + // // "foo must be a number, an object or a string (was undefined)" + // // ) + // // this could be improved, currently a bit counterintuitive because of + // // the inconsistency between `domainOf` and typeof + // attest(types.d({ foo: null }).toString()).snap( + // "foo must be a function (was null)" + // ) + // }) it("multi", () => { const naturalNumber = type("integer>0") diff --git a/ark/type/__tests__/undeclaredKeys.test.ts b/ark/type/__tests__/undeclaredKeys.test.ts index 843ae1da6f..b19eb84c6f 100644 --- a/ark/type/__tests__/undeclaredKeys.test.ts +++ b/ark/type/__tests__/undeclaredKeys.test.ts @@ -59,7 +59,7 @@ b must be removed`) "reject" ) attest(o({ a: 2, b: true }).toString()).snap( - "a must be a string or removed (was number)" + "a must be a string or removed (was 2)" ) }) }) diff --git a/ark/type/__tests__/union.test.ts b/ark/type/__tests__/union.test.ts index f760431169..27559d325a 100644 --- a/ark/type/__tests__/union.test.ts +++ b/ark/type/__tests__/union.test.ts @@ -36,7 +36,7 @@ contextualize(() => { attest(t.json).equals(expected.json) }) - it("union of true and false reduces to boolean", () => { + it("boolean is a union of true | false", () => { const t = type("true|false") attest(t.infer).type.toString("boolean") attest(t.json).equals(type("boolean").json) @@ -106,6 +106,55 @@ contextualize(() => { | 44 | 45 >(t.infer) + + attest(t.json).snap([ + { unit: 0 }, + { unit: 10 }, + { unit: 11 }, + { unit: 12 }, + { unit: 13 }, + { unit: 14 }, + { unit: 15 }, + { unit: 16 }, + { unit: 17 }, + { unit: 18 }, + { unit: 19 }, + { unit: 1 }, + { unit: 20 }, + { unit: 21 }, + { unit: 22 }, + { unit: 23 }, + { unit: 24 }, + { unit: 25 }, + { unit: 26 }, + { unit: 27 }, + { unit: 28 }, + { unit: 29 }, + { unit: 2 }, + { unit: 30 }, + { unit: 31 }, + { unit: 32 }, + { unit: 33 }, + { unit: 34 }, + { unit: 35 }, + { unit: 36 }, + { unit: 37 }, + { unit: 38 }, + { unit: 39 }, + { unit: 3 }, + { unit: 40 }, + { unit: 41 }, + { unit: 42 }, + { unit: 43 }, + { unit: 44 }, + { unit: 45 }, + { unit: 4 }, + { unit: 5 }, + { unit: 6 }, + { unit: 7 }, + { unit: 8 }, + { unit: 9 } + ]) }) const expected = () => diff --git a/ark/type/api.ts b/ark/type/api.ts index ca01717f62..295759fdb7 100644 --- a/ark/type/api.ts +++ b/ark/type/api.ts @@ -1,4 +1,4 @@ -export { ArkError as ArkError, ArkErrors } from "@arktype/schema" +export { ArkError, ArkErrors } from "@arktype/schema" export type { Ark, ArkConfig, Out } from "@arktype/schema" export { ambient, ark, declare, define, match, type } from "./ark.js" export { Module } from "./module.js" diff --git a/ark/type/generic.ts b/ark/type/generic.ts index 4a47fad1e3..8ee1561864 100644 --- a/ark/type/generic.ts +++ b/ark/type/generic.ts @@ -1,8 +1,8 @@ import { + arkKind, type GenericNodeInstantiation, type GenericProps, - type RootScope, - arkKind + type RootScope } from "@arktype/schema" import { Callable, type conform } from "@arktype/util" import type { inferDefinition } from "./parser/definition.js" @@ -11,6 +11,7 @@ import type { parseGenericParams } from "./parser/generic.js" import type { Type, inferTypeRoot, validateTypeRoot } from "./type.js" + export type validateParameterString<params extends string> = parseGenericParams<params> extends GenericParamsParseError<infer message> ? message diff --git a/ark/type/match.ts b/ark/type/match.ts index f6f2f3df77..8eb34da3f6 100644 --- a/ark/type/match.ts +++ b/ark/type/match.ts @@ -156,63 +156,63 @@ export type MatchInvocation<ctx extends MatchInvocationContext> = < : ReturnType<ctx["thens"][i]> }[numericStringKeyOf<ctx["thens"]>] -export const createMatchParser = <$>($: Scope): MatchParser<$> => { - return (() => {}).bind($) as never - // const matchParser = (isRestricted: boolean) => { - // const handledCases: { when: RawRoot; then: Morph }[] = [] - // let defaultCase: ((x: unknown) => unknown) | null = null - - // const parser = { - // when: (when: unknown, then: Morph) => { - // handledCases.push({ when: $.parseRoot(when, {}), then }) - - // return parser - // }, - - // finalize: () => { - // // TODO: exhaustiveness checking - // const branches = handledCases.flatMap(({ when, then }) => { - // if (when.kind === "union") { - // return when.branches.map((branch) => ({ - // in: branch, - // morph: then - // })) - // } - // if (when.kind === "morph") { - // return [{ in: when, morph: [when.morph, then] }] - // } - // return [{ in: when, morph: then }] - // }) - // if (defaultCase) { - // branches.push({ in: keywordNodes.unknown, morph: defaultCase }) - // } - // const matchers = $.node("union", { - // branches, - // ordered: true - // }) - // return matchers.assert - // }, - - // orThrow: () => { - // // implicitly finalize, we don't need to do anything else because we throw either way - // return parser.finalize() - // }, - - // default: (x: unknown) => { - // if (x instanceof Function) { - // defaultCase = x as never - // } else { - // defaultCase = () => x - // } - - // return parser.finalize() - // } - // } - - // return parser - // } - - // return Object.assign(() => matchParser(false), { - // only: () => matchParser(true) - // }) as never -} +export const createMatchParser = <$>($: Scope): MatchParser<$> => + (() => {}).bind($) as never + +// const matchParser = (isRestricted: boolean) => { +// const handledCases: { when: RawRoot; then: Morph }[] = [] +// let defaultCase: ((x: unknown) => unknown) | null = null + +// const parser = { +// when: (when: unknown, then: Morph) => { +// handledCases.push({ when: $.parseRoot(when, {}), then }) + +// return parser +// }, + +// finalize: () => { +// // TODO: exhaustiveness checking +// const branches = handledCases.flatMap(({ when, then }) => { +// if (when.kind === "union") { +// return when.branches.map((branch) => ({ +// in: branch, +// morph: then +// })) +// } +// if (when.kind === "morph") { +// return [{ in: when, morph: [when.morph, then] }] +// } +// return [{ in: when, morph: then }] +// }) +// if (defaultCase) { +// branches.push({ in: keywordNodes.unknown, morph: defaultCase }) +// } +// const matchers = $.node("union", { +// branches, +// ordered: true +// }) +// return matchers.assert +// }, + +// orThrow: () => { +// // implicitly finalize, we don't need to do anything else because we throw either way +// return parser.finalize() +// }, + +// default: (x: unknown) => { +// if (x instanceof Function) { +// defaultCase = x as never +// } else { +// defaultCase = () => x +// } + +// return parser.finalize() +// } +// } + +// return parser +// } + +// return Object.assign(() => matchParser(false), { +// only: () => matchParser(true) +// }) as never diff --git a/ark/type/package.json b/ark/type/package.json index a06e1e1ee7..b58468ea5f 100644 --- a/ark/type/package.json +++ b/ark/type/package.json @@ -20,9 +20,6 @@ "./config": "./out/config.js", "./internal/*": "./out/*" }, - "imports": { - "#foo": "../schema/api.ts" - }, "files": [ "out" ], diff --git a/ark/type/parser/definition.ts b/ark/type/parser/definition.ts index 34948ee31a..c3ab2c7a0b 100644 --- a/ark/type/parser/definition.ts +++ b/ark/type/parser/definition.ts @@ -63,7 +63,10 @@ export const parseObject = (def: object, ctx: ParseContext): BaseRoot => { } export type inferDefinition<def, $, args> = - [def] extends [anyOrNever] ? def + [def] extends [anyOrNever] ? + def extends never ? + never + : any : def extends type.cast<infer t> | ThunkCast<infer t> ? t : def extends string ? inferString<def, $, args> : def extends array ? inferTuple<def, $, args> diff --git a/ark/type/parser/string/shift/operand/operand.ts b/ark/type/parser/string/shift/operand/operand.ts index 19b19720b9..97b076ca12 100644 --- a/ark/type/parser/string/shift/operand/operand.ts +++ b/ark/type/parser/string/shift/operand/operand.ts @@ -3,10 +3,11 @@ import type { StaticState, state } from "../../reduce/static.js" import type { BaseCompletions } from "../../string.js" import { Scanner } from "../scanner.js" import { - type EnclosingQuote, - type EnclosingStartToken, enclosingChar, - parseEnclosed + enclosingQuote, + parseEnclosed, + type EnclosingQuote, + type EnclosingStartToken } from "./enclosed.js" import { parseUnenclosed, writeMissingOperandMessage } from "./unenclosed.js" @@ -17,8 +18,11 @@ export const parseOperand = (s: DynamicState): void => : s.scanner.lookaheadIsIn(Scanner.whiteSpaceTokens) ? parseOperand(s.shiftedByOne()) : s.scanner.lookahead === "d" ? - s.shiftedByOne().scanner.lookaheadIsIn(enclosingChar) ? - parseEnclosed(s, `d${s.scanner.shift()}` as EnclosingStartToken) + s.scanner.nextLookahead in enclosingQuote ? + parseEnclosed( + s, + `${s.scanner.shift()}${s.scanner.shift()}` as EnclosingStartToken + ) : parseUnenclosed(s) : parseUnenclosed(s) diff --git a/ark/type/parser/string/shift/scanner.ts b/ark/type/parser/string/shift/scanner.ts index b0f7d72957..c9ead3e15d 100644 --- a/ark/type/parser/string/shift/scanner.ts +++ b/ark/type/parser/string/shift/scanner.ts @@ -1,7 +1,7 @@ -import { type Dict, isKeyOf } from "@arktype/util" +import { isKeyOf, type Dict } from "@arktype/util" import type { Comparator } from "../reduce/shared.js" -export class Scanner<Lookahead extends string = string> { +export class Scanner<lookahead extends string = string> { private chars: string[] private i: number @@ -11,12 +11,16 @@ export class Scanner<Lookahead extends string = string> { } /** Get lookahead and advance scanner by one */ - shift(): Lookahead { - return (this.chars[this.i++] ?? "") as Lookahead + shift(): lookahead { + return (this.chars[this.i++] ?? "") as never } - get lookahead(): Lookahead { - return (this.chars[this.i] ?? "") as Lookahead + get lookahead(): lookahead { + return (this.chars[this.i] ?? "") as never + } + + get nextLookahead(): string { + return this.chars[this.i + 1] ?? "" } get length(): number { @@ -65,13 +69,13 @@ export class Scanner<Lookahead extends string = string> { return this.chars.slice(start, end).join("") } - lookaheadIs<Char extends Lookahead>(char: Char): this is Scanner<Char> { + lookaheadIs<char extends lookahead>(char: char): this is Scanner<char> { return this.lookahead === char } - lookaheadIsIn<Tokens extends Dict>( - tokens: Tokens - ): this is Scanner<Extract<keyof Tokens, string>> { + lookaheadIsIn<tokens extends Dict>( + tokens: tokens + ): this is Scanner<Extract<keyof tokens, string>> { return this.lookahead in tokens } } diff --git a/ark/type/scope.ts b/ark/type/scope.ts index 1271cc39f9..17205038d7 100644 --- a/ark/type/scope.ts +++ b/ark/type/scope.ts @@ -27,7 +27,6 @@ import { type nominal, type show } from "@arktype/util" -import type { type } from "./ark.js" import { Generic } from "./generic.js" import { createMatchParser, type MatchParser } from "./match.js" import type { Module } from "./module.js" @@ -146,9 +145,7 @@ export type moduleKeyOf<$> = { export type tryInferSubmoduleReference<$, token> = token extends `${infer submodule extends moduleKeyOf<$>}.${infer subalias}` ? subalias extends keyof $[submodule] ? - $[submodule][subalias] extends type.cast<infer t> ? - t - : never + $[submodule][subalias] : never : never diff --git a/ark/util/__tests__/hkt.test.ts b/ark/util/__tests__/hkt.test.ts index ed5590b1bf..dd152fa04c 100644 --- a/ark/util/__tests__/hkt.test.ts +++ b/ark/util/__tests__/hkt.test.ts @@ -57,9 +57,8 @@ contextualize(() => { const AddD = new (class AddD extends Hkt.UnaryKind { hkt = ( args: conform<this[Hkt.args], { c: number }> - ): show<typeof args & { d: (typeof args)["c"] }> => { - return Object.assign(args, { d: args.c } as const) - } + ): show<typeof args & { d: (typeof args)["c"] }> => + Object.assign(args, { d: args.c } as const) })() // @ts-expect-error attest(() => Hkt.pipe(AddB, AddD)).type.errors.snap( diff --git a/ark/util/arrays.ts b/ark/util/arrays.ts index cb7c0f0aba..7006eda2bc 100644 --- a/ark/util/arrays.ts +++ b/ark/util/arrays.ts @@ -229,20 +229,20 @@ export type groupableKeyOf<t> = { [k in keyof t]: t[k] extends PropertyKey ? k : never }[keyof t] -export type groupBy<element, discriminator extends groupableKeyOf<element>> = { - [k in element[discriminator] & PropertyKey]?: (element extends unknown ? - isDisjoint<element[discriminator], k> extends true ? +export type groupBy<element, discriminant extends groupableKeyOf<element>> = { + [k in element[discriminant] & PropertyKey]?: (element extends unknown ? + isDisjoint<element[discriminant], k> extends true ? never : element : never)[] } & unknown -export const groupBy = <element, discriminator extends groupableKeyOf<element>>( +export const groupBy = <element, discriminant extends groupableKeyOf<element>>( array: readonly element[], - discriminator: discriminator -): groupBy<element, discriminator> => + discriminant: discriminant +): groupBy<element, discriminant> => array.reduce<Record<PropertyKey, any>>((result, item) => { - const key = item[discriminator] as never + const key = item[discriminant] as never result[key] ??= [] result[key].push(item) return result diff --git a/ark/util/functions.ts b/ark/util/functions.ts index d17244c891..a68f45b1c6 100644 --- a/ark/util/functions.ts +++ b/ark/util/functions.ts @@ -1,5 +1,5 @@ import { throwInternalError } from "./errors.js" -import { NoopBase } from "./records.js" +import { NoopBase, unset } from "./records.js" export const bound = ( target: Function, @@ -31,6 +31,11 @@ export const cached = <self>( return value } +export const cachedThunk = <t>(thunk: () => t): (() => t) => { + let result: t | unset = unset + return () => (result === unset ? (result = thunk()) : result) +} + export const isThunk = <value>( value: value ): value is Extract<value, Thunk> extends never ? value & Thunk @@ -40,6 +45,17 @@ export type Thunk<ret = unknown> = () => ret export type thunkable<t> = t | Thunk<t> +export const tryCatch = <returns, onError = never>( + fn: () => returns, + onError?: (e: unknown) => onError +): returns | onError => { + try { + return fn() + } catch (e) { + return onError?.(e) as onError + } +} + export const DynamicFunction = class extends Function { constructor(...args: [string, ...string[]]) { const params = args.slice(0, -1) @@ -91,3 +107,20 @@ export class Callable< export type Guardable<input = unknown, narrowed extends input = input> = | ((In: input) => In is narrowed) | ((In: input) => boolean) + +/** + * Checks if the environment has Content Security Policy (CSP) enabled, + * preventing JIT-optimized code from being compiled via new Function(). + * + * @returns `true` if a function created using new Function() can be + * successfully invoked in the environment, `false` otherwise. + * + * The result is cached for subsequent invocations. + */ +export const envHasCsp = cachedThunk((): boolean => { + try { + return new Function("return false")() + } catch (e) { + return true + } +}) diff --git a/ark/util/registry.ts b/ark/util/registry.ts index 39299d5ff9..de7e1328dc 100644 --- a/ark/util/registry.ts +++ b/ark/util/registry.ts @@ -39,11 +39,10 @@ export type RegisteredReference<to extends string = string> = `$ark.${to}` export const isDotAccessible = (keyName: string): boolean => /^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(keyName) -export const compileSerializedValue = (value: unknown): string => { - return hasDomain(value, "object") || typeof value === "symbol" ? - registeredReference(value) - : serializePrimitive(value as SerializablePrimitive) -} +export const compileSerializedValue = (value: unknown): string => + hasDomain(value, "object") || typeof value === "symbol" ? + registeredReference(value) + : serializePrimitive(value as SerializablePrimitive) const baseNameFor = (value: object | symbol) => { switch (typeof value) { diff --git a/package.json b/package.json index 6354fbcb9e..ca1b4bf302 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ }, "pnpm": { "overrides": { - "esbuild": "0.21.2" + "esbuild": "0.21.3" } }, "mocha": {