diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index d706ed7618..ce19b33bd0 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -17,24 +17,24 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall - community +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community Examples of unacceptable behavior include: -- The use of sexualized language or imagery, and sexual attention or advances of - any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email address, - without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Enforcement Responsibilities diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cfcb23acbb..60ddea5007 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -ArkType values the time of its users and contributors as much as its maintainers, so our goal is for the process to be as efficient and straightforward as possible. Whether this is your first pull request or you're a seasoned open source contributor, this guide is the perfect place to start. If you have any other questions, please don't hesitate to [create an issue on GitHub](https://github.com/arktypeio/arktype/issues/new) or reach out [on our Discord](https://discord.gg/WSNF3Kc4xh). +ArkType values the time of its users and contributors as much as its maintainers, so our goal is for the process to be as efficient and straightforward as possible. Whether this is your first pull request or you're a seasoned open source contributor, this guide is the perfect place to start. If you have any other questions, please don't hesitate to [create an issue on GitHub](https://github.com/arktypeio/arktype/issues/new) or reach out [on our Discord](https://arktype.io/discord). ## Sending a Pull Request @@ -48,27 +48,27 @@ git checkout -b amazing-feature 6. Do your best to write code that is stylistically consistent with its context. The linter will help with this, but it won't catch everything. Here's a few general guidelines: - - Favor functions over classes - - Favor arrow functions outside of classes - - Favor types over interfaces - - Favor mutation over copying objects in perf-sensitive contexts - - Favor clarity in naming with the following exceptions: - - Ubiquitous variables/types. For example, use `s` over `dynamicParserState` for a variable of type DynamicParserState that is used in the same way across many functions. - - Ephemeral variables whose contents can be trivially inferred from context. For example, prefer `rawKeyDefinitions.map(_ => _.trim())` to `rawKeyDefinitions.map(rawKeyDefinition => rawKeyDefinition.trim())`. + - Favor functions over classes + - Favor arrow functions outside of classes + - Favor types over interfaces + - Favor mutation over copying objects in perf-sensitive contexts + - Favor clarity in naming with the following exceptions: + - Ubiquitous variables/types. For example, use `s` over `dynamicParserState` for a variable of type DynamicParserState that is used in the same way across many functions. + - Ephemeral variables whose contents can be trivially inferred from context. For example, prefer `rawKeyDefinitions.map(_ => _.trim())` to `rawKeyDefinitions.map(rawKeyDefinition => rawKeyDefinition.trim())`. We also have some unique casing rules for our TypeScript types to making writing isomorphic code easier: -- Use `CapitalCase` for... +- Use `CapitalCase` for... - - Non-generic types (e.g. `SomeData`) - - Generic types with noun names, like `Array`. As a rule of thumb, your generic should be named this way if all its parameters have defaults (unfortunately TS's builtin `Array` type doesn't have a default parameter, but it should have been `unknown`!) + - Non-generic types (e.g. `SomeData`) + - Generic types with noun names, like `Array`. As a rule of thumb, your generic should be named this way if all its parameters have defaults (unfortunately TS's builtin `Array` type doesn't have a default parameter, but it should have been `unknown`!) -- Use `camelCase` for... +- Use `camelCase` for... - - Generic types with verb names like `inferDomain`. Types named this way should always have at least one required parameter. - - Parameter names, e.g. `t` in `Array` + - Generic types with verb names like `inferDomain`. Types named this way should always have at least one required parameter. + - Parameter names, e.g. `t` in `Array` -7. Once you've made the changes you want to and added corresponding unit tests, run the `prChecks` command in the project root and address any problems: +7. Once you've made the changes you want to and added corresponding unit tests, run the `prChecks` command in the project root and address any errors: ```sh pnpm prChecks @@ -77,7 +77,7 @@ pnpm prChecks You can also run any of these commands individually: ```sh @lineFrom:package.json:scripts/prChecks -"pnpm install && pnpm lint && pnpm checkFormat && pnpm testRepo && pnpm buildRepo" +"pnpm install && pnpm lint && pnpm testRepo && pnpm buildRepo" ``` All of these commands will run as part of our CI process and must succeed in order for us to accept your Pull Request. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0efce29054..beccdce051 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,9 +17,9 @@ assignees: "ssalbdivad" ### 🧩 Context -- ArkType version: -- TypeScript version (4.8+): -- Other context you think may be relevant (Node version, OS, etc.): +- ArkType version: +- TypeScript version (4.8, 4.9, or 5.0): +- Other context you think may be relevant (Node version, OS, etc.): ### 🧑‍💻 Repro @@ -31,7 +31,7 @@ assignees: "ssalbdivad" 5. Copy the source code you used to repro the bug and paste it into the code block below. --> -https://stackblitz.com/edit/arktype-repro?devToolsHeight=33&file=demo.ts +https://stackblitz.com/edit/arktype-bug?devToolsHeight=33&file=demo.ts ```ts import { type, scope } from "arktype" diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 84d7cd2735..a4a070760d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -8,18 +8,21 @@ assignees: "" # Request a feature + ### 🤷 Motivation -What problem are you having? - -Why should we prioritize solving it? + ### 💡 Solution -How do you think we should solve the problem? - -Why do you think this is the best solution? - -Did you consider any alternatives? + diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 00f4a0a7f0..14a1535004 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -2,26 +2,26 @@ name: Setup repo description: Install dependencies and perform setup for https://github.com/arktypeio/arktype inputs: - node: - default: 18 + node: + default: lts/* runs: - using: composite - steps: - - name: Setup pnpm - uses: pnpm/action-setup@v2 - with: - version: 8.3.1 + using: composite + steps: + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8.3.1 - - name: Setup Node (${{ inputs.node }}) - uses: actions/setup-node@v3 - with: - node-version: ${{ inputs.node }} + - name: Setup Node (${{ inputs.node }}) + uses: actions/setup-node@v3 + with: + node-version: ${{ inputs.node }} - - name: Install dependencies - shell: bash - run: pnpm install + - name: Install dependencies + shell: bash + run: pnpm install - - name: Build - shell: bash - run: pnpm buildRepo + - name: Build + shell: bash + run: pnpm build diff --git a/.github/semantic.yml b/.github/semantic.yml index 26993f3391..90cf41d4b0 100644 --- a/.github/semantic.yml +++ b/.github/semantic.yml @@ -1,4 +1,9 @@ titleOnly: true scopes: - - attest - - arktype.io + - attest + - dark + - fs + - repo + - schema + - type + - utils diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9de02e8c4d..b684715292 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,60 +1,68 @@ name: pr on: - pull_request: - branches: [main] + pull_request: + branches: [main] defaults: - run: - shell: bash + run: + shell: bash jobs: - core: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Setup repo - uses: ./.github/actions/setup - - - name: prChecks - run: pnpm prChecks - - compatibility: - needs: core - timeout-minutes: 20 - strategy: - matrix: - # https://github.com/arktypeio/arktype/issues/738 - node: [16, 18] - os: [windows-latest, macos-latest] - include: - - os: ubuntu-latest - node: 16 - fail-fast: false - - runs-on: ${{ matrix.os }} - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Setup repo - uses: ./.github/actions/setup - with: - node: ${{ matrix.node }} - - - name: Test - run: pnpm testRepo - - prChecks: - needs: compatibility - timeout-minutes: 1 - runs-on: ubuntu-latest - steps: - - run: echo All checks succeeded! ⛵ + core: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup repo + uses: ./.github/actions/setup + + - name: prChecks + run: pnpm prChecks + + - name: TSC Diagnostics Diff + uses: beerose/tsc-diff-action@v0.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + leave-comment: true + + compatibility: + needs: core + timeout-minutes: 20 + strategy: + matrix: + node: [lts/*] + os: [windows-latest, macos-latest] + include: + - os: ubuntu-latest + node: lts/-1 + # https://github.com/arktypeio/arktype/issues/738 + # - os: ubuntu-latest + # node: latest + fail-fast: false + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup repo + uses: ./.github/actions/setup + with: + node: ${{ matrix.node }} + + # - name: Test + # run: pnpm testRepo + + prChecks: + needs: compatibility + timeout-minutes: 1 + runs-on: ubuntu-latest + steps: + - run: echo All checks succeeded! ⛵ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c8f12669fa..fe7ccd8151 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,52 +1,52 @@ name: publish on: - push: - branches: [main] + push: + branches: [main] defaults: - run: - shell: bash + run: + shell: bash jobs: - update-gh-pages: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Setup repo - uses: ./.github/actions/setup - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dev/arktype.io/dist - - create-release-pr: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout repo - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Setup repo - uses: ./.github/actions/setup - - - name: Create and publish versions - uses: changesets/action@v1 - with: - commit: "chore: update versions" - title: "chore: update versions" - cwd: "./dev/configs" - version: pnpm ci:version - publish: pnpm ci:publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + update-gh-pages: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup repo + uses: ./.github/actions/setup + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./ark/docs/out + + create-release-pr: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup repo + uses: ./.github/actions/setup + + - name: Create and publish versions + uses: changesets/action@v1 + with: + commit: "chore: update versions" + title: "chore: update versions" + cwd: "./dev" + version: pnpm ci:version + publish: pnpm ci:publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 9002a55b52..1263635122 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist +out node_modules temp tmp @@ -7,13 +8,11 @@ tmp *.tsbuildinfo .DS_Store .docusaurus +.astro .cache-loader .attest +.tstrace coverage -/**/themes/*.json # we avoid committing the root pnpm-lock in order to keep the root of the repo as clean as possible. # we can get away with this to since we're only installing devDependencies and they're all pinned. /pnpm-lock.yaml -# this gets generated during certain integration tests, but we never want to actually check it in -# (this can be removed in the future if we do need it) -/.npmrc diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 33de65f4df..a6a61e5cad 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,12 @@ { - "recommendations": [ - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", - "hbenl.vscode-mocha-test-adapter" - ] + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + // Run/debug tests inline via VSCode's Test Explorer + "hbenl.vscode-mocha-test-adapter", + // Syntax highlighting for strings in ArkType definitions + "arktypeio.arkdark", + // Playground-like version dropdown for TypeScript versions + "typeholes.ts-versions-switcher" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index db17c2a5b9..db25220585 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,13 +1,13 @@ // A launch configuration that launches the extension inside a new window { - "version": "0.1.0", - "configurations": [ - { - "name": "Launch Extension", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}/dev/arkdark"] - } - ] + "version": "0.1.0", + "configurations": [ + { + "name": "ArkDark Dev", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": ["--extensionDevelopmentPath=${workspaceRoot}/ark/dark"] + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 4df5d76343..451da58fb5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,30 +1,46 @@ { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": ["source.fixAll.eslint"], - "editor.quickSuggestions": { - "strings": true - }, - "eslint.codeActionsOnSave.rules": [ - "import/order", - "sort-imports", - "import/no-duplicates", - "unicorn/prefer-node-protocol", - "@typescript-eslint/consistent-type-imports", - "object-shorthand" - ], - "typescript.preferences.autoImportFileExcludePatterns": ["dist"], - "typescript.enablePromptUseWorkspaceTsdk": true, - "typescript.tsdk": "./node_modules/typescript/lib", - "typescript.tsserver.experimental.enableProjectDiagnostics": true, - "typescript.preferences.importModuleSpecifierEnding": "js", - "prettier.ignorePath": "dev/configs/.prettierignore", - "mochaExplorer.nodeArgv": [ - "--loader=ts-node/esm", - "--no-warnings=ExperimentalWarning" - ], - "mochaExplorer.env": { - "ATTEST_CONFIG": "{ \"skipTypes\": true }" - }, - "testExplorer.useNativeTesting": true + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": [ + "editor.formatOnSave", + "source.fixAll.eslint", + "source.sortImports" + ], + "eslint.codeActionsOnSave.rules": [ + "object-shorthand", + "@typescript-eslint/consistent-type-imports", + "import/no-duplicates", + "@typescript-eslint/no-import-type-side-effects", + "curly" + ], + "typescript.preferences.preferTypeOnlyAutoImports": true, + "typescript.preferences.autoImportFileExcludePatterns": [ + "out", + // 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/main.ts", + "./ark/schema/main.ts" + ], + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + // IF YOU UPDATE THE MOCHA CONFIG HERE, PLEASE ALSO UPDATE package.json/mocha AND ark/repo/mocha.jsonc + "mochaExplorer.nodeArgv": ["--import=tsx"], + // ignore attest since it requires type information + "mochaExplorer.ignore": ["ark/attest/**/*"], + "mochaExplorer.require": "ark/repo/mocha.globalSetup.ts", + "mochaExplorer.timeout": 0, + "mochaExplorer.env": { + "ATTEST_skipTypes": "true" + }, + "testExplorer.useNativeTesting": true, + "editor.unicodeHighlight.allowedCharacters": { + // Hair Space: used as a sentinel for error messages + " ": true + }, + "astro.language-server.ls-path": "ark/docs/node_modules/astro", + "search.exclude": { + "**/out": true + }, + "debug.javascript.terminalOptions": { + "skipFiles": ["/**", "**/node_modules/**"] + } } diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index fa653014e2..0000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,494 +0,0 @@ -# arktype - -## 1.0.29-alpha - -### Patch Changes - -- fix: don't freeze definitions for compatibility with sass - -## 1.0.28-alpha - -### Patch Changes - -- Allow instanceof abstract classes, avoid treating some function props as morphs - -## 1.0.27-alpha - -### Patch Changes - -- Improve static parse error contextual summaries - - Thanks @Hsiwe! https://github.com/arktypeio/arktype/pull/873 - -## 1.0.26-alpha - -### Patch Changes - -- Fix an issue where optional paths could be used as discriminants - -## 1.0.25-alpha - -### Patch Changes - -- Fix an issue causing some morphs to not be properly extracted when .infer(red) - -## 1.0.24-alpha - -### Patch Changes - -- Add inferDefinition, validateDefinition and PrecompiledDefaults to primary entrypoint - -## 1.0.23-alpha - -### Patch Changes - -- Preserve named classes not including morphs - -## 1.0.22-alpha - -### Patch Changes - -- ### Fix a bug inferring certain recursive unions. - - Previously, a scoped type like this failed to infer correctly. Thanks to [@Vanilagy](https://github.com/Vanilagy) for the repro! - - ```ts - scope({ - a: { - name: '"a"' - }, - b: { - name: '"b"', - children: "(a|b)[]" - } - }) - ``` - -## 1.0.21-alpha - -### Patch Changes - -- Remove preinstall script from package.json - -## 1.0.20-alpha - -### Patch Changes - -- # Fixes a bug causing intersections including cross scope references to be inferred as `unknown` - - Unfortunately, some cross-scope operations will still result in an error at runtime. You will know at build time if this occurs by a message in an intersection like "Unable to resolve alias 'myExternalAlias'". The workaround is to use the in-scope type parser as follows until next release for these scenarios: - - Unions: - - ```ts - const $ = scope({ - a: "'abc'", - b: { "c?": "a" } - }) - const types = $.compile() - // This fails if you don't use scoped type for now, fixing in next release - const t = $.type([types.b, "|", { extraProp: "string" }]) - ``` - - Intersections: - - ```ts - const $ = scope({ - a: "'abc'", - b: { "c?": "a" } - }) - const types = $.compile() - // This fails if you don't use scoped type for now, fixing in next release - const t = $.type([types.b, "&", { extraProp: "string" }]) - ``` - -## 1.0.19-alpha - -### Patch Changes - -- Make types keep JSDoc for required keys - -## 1.0.18-alpha - -### Patch Changes - -- Fix build entrypoint paths - -## 1.0.17-alpha - -### Patch Changes - -- ci: revert to dual cjs+esm build output - -## 1.0.16-alpha - -### Patch Changes - -- fix: internal type error in compiled output - -## 1.0.15-alpha - -### Patch Changes - -- Fix a type error affecting users with skipLibCheck unset - -## 1.0.14-alpha - -### Patch Changes - -- ## add a syntax error when defining an expression with multiple right bounds - - Ensures expressions like the following result in a syntax error during type validation (will currently not throw at runtime): - - ```ts - // Type Error: `Expression 'number' must have at most one right bound` - const boundedNumber = type("number>0<=200") - ``` - -- ## fix array validation in strict and distilled modes - - Previously, attempting to validate an array with "keys" set to "distilled" or "strict" would yield incorrect results. - - Now, types like this behave as expected: - - ```ts - const strictArray = type("string[]", { keys: "strict" }) - // data = ["foo", "bar"] - const { data, problems } = strictArray(["foo", "bar"]) - ``` - -## 1.0.13-alpha - -### Patch Changes - -- check all keys when in strict or distilled mode, even if one fails - -## 1.0.12-alpha - -### Patch Changes - -- ## fixed a bug causing array elements after the first failure not to be checked - - Previously, when checking an array, we'd bail out after the first failed element. Since all elements are at the same level, we should check each and provide problems when relevant, regardless of the validation result of previous elements (unless in failFast mode). - - Thanks to @PointSingularity for this repro (can be found in the associated issue, https://github.com/arktypeio/arktype/issues/710): - - ```ts - import { type, scope } from "arktype" - - export const badType = type("number[]") - export const { data, problems } = badType([1, 2, null, null]) - - // count should be 2 - console.log("Problem count: ", problems?.count ?? 0) - ``` - -- ## allow a custom path to be specified when creating a problem using a string[] - - Previously, creating a problem at a custom Path from a narrow function required importing the Path utility, which is not exposed through the main API entrypoint. This allows path to be specified as a simple string[], e.g.: - - ```ts - const abEqual = type([ - { - a: "number", - b: "number" - }, - "=>", - ({ a, b }, problems) => { - if (a === b) { - return true - } - problems.mustBe("equal to b", { path: ["a"] }) - problems.mustBe("equal to a", { path: ["b"] }) - return false - } - ]) - ``` - - Addresses https://github.com/arktypeio/arktype/issues/709. - -## 1.0.11-alpha - -### Patch Changes - -- ## remove String, Number, Boolean, Object and Array from the default jsObjects scope - - These types are a footgun in TypeScript. You almost always want to use `string`, `number`, `boolean`, `object` or `unknown[]` instead. Particularly with the addition of autocomplete, we don't want to be confusing people by constantly suggesting `string` and `String`. - - If you do want to use them, you can still define them like any other `"instanceof"` type: - - ```ts - const stringObject = type(["instanceof", String]) - ``` - -- Thanks [@{](https://github.com/{)! - ## add autocomplete for string definitions - - Now, when you start typing a string definition, you'll see all valid completions for that definition based on the keywords in your current scope: - - ```ts - type({ - // suggests all built-in keywords (good way to see what's available!) - name: "" - }) - - type({ - // suggests "string" | "semver" | "symbol" - name: "s" - }) - - type({ - // suggests "string" - name: "str", - // suggests "number|undefined" | "number|unknown" - age: "number|un" - }) - - scope({ - - name: "string", - age: "number|undefined" - }, - // suggests "user" | "undefined" | "unknown" | "uuid" | "uppercase" - admin: "u" - }) - ``` - - Initially, I was hesitant to add funcitonality like this, because it sometimes leads to type errors like '"" is not assignable to ("string" | "number" | ...a bunch of keywords...)", which is significantly less clear than the previous message "'' is unresolvable." - - That said, the DX was just too good to pass up. Try it out and let me know if you agree 🔥 - -## 1.0.10-alpha - -### Patch Changes - -- add "inferIn" prop for extracting type input - -## 1.0.9-alpha - -### Patch Changes - -- improve type summaries for tuple/array intersections - - This change improves the inferred types of array intersections including one or more tuples. - - ```ts - const tupleAndArray = type([ - [{ a: "string" }], - "&", - [{ b: "boolean" }, "[]"] - ]) - - // Failed to preserve tuple when inferring result - type PreviousResult = { a: string; b: boolean }[] - - // Correctly preserves tuple literal - type UpdatedResult = [{ a: string; b: boolean }] - ``` - - Thanks to KingPhipps on Twitter for [the inspiration](https://twitter.com/KingPhipps/status/1635212259973795841?s=20)! - -## 1.0.8-alpha - -### Patch Changes - -- add an "assert" utility to type instances that either directly returns valid data or throws a TypeError - - ```ts - const t = type("string") - // "foo" - const resultOne = t.assert("foo") - // Throws: TypeError: Must be a string (was number) - const resultTwo = t.assert(5) - ``` - -## 1.0.7-alpha - -### Patch Changes - -- Allow discrimination between common builtin classes - - Previously, types like the following were incorrectly treated as non-discriminatable unions: - - ```ts - const arrayOrDate = type([["instanceof", Array], "|", ["instanceof", Date]]) - - attest(t.flat).snap([ - ["domain", "object"], - // Whoops! Should have been a switch based on "class" - [ - "branches", - [[["class", "(function Array)"]], [["class", "(function Date)"]]] - ] - ]) - ``` - - Now, unions like these are correctly discriminated if they occur anywhere in the type: - - ```ts - const arrayOrDate = type([["instanceof", Array], "|", ["instanceof", Date]]) - - attest(t.flat).snap([ - ["domain", "object"], - // Correctly able to determine which branch we are on in constant time - ["switch", { path: [], kind: "class", cases: { Array: [], Date: [] } }] - ]) - ``` - -## 1.0.5-alpha - -### Patch Changes - -- [#663](https://github.com/arktypeio/arktype/pull/663) [`27b1d972`](https://github.com/arktypeio/arktype/commit/27b1d972e3fe5044571bd16508dd49ddee0d7592) Thanks [@ssalbdivad](https://github.com/ssalbdivad)! - temporarily disable numeric literal narrow validation in range and divisibility expressions - - Unfortunately, our StackBlitz demos rely on an older version of TypeScript (<4.8) that does not support number literal narrowing. Hopefully we can migrate them to WebContainers or find another platform to host our demos and reenable this feature. - -- [#663](https://github.com/arktypeio/arktype/pull/663) [`27b1d972`](https://github.com/arktypeio/arktype/commit/27b1d972e3fe5044571bd16508dd49ddee0d7592) Thanks [@ssalbdivad](https://github.com/ssalbdivad)! - fixed a bug affecting the traversal of object unions with distilled keys - -- [#663](https://github.com/arktypeio/arktype/pull/663) [`27b1d972`](https://github.com/arktypeio/arktype/commit/27b1d972e3fe5044571bd16508dd49ddee0d7592) Thanks [@ssalbdivad](https://github.com/ssalbdivad)! - Fixed a bug that caused keys to be prematurely removed in "distilled" mode within a union - -- [#663](https://github.com/arktypeio/arktype/pull/663) [`27b1d972`](https://github.com/arktypeio/arktype/commit/27b1d972e3fe5044571bd16508dd49ddee0d7592) Thanks [@ssalbdivad](https://github.com/ssalbdivad)! - fix a bug affecting the keyof operator when used with the intersection of an unbounded array and a tuple or record including a numeric key - -- [#663](https://github.com/arktypeio/arktype/pull/663) [`27b1d972`](https://github.com/arktypeio/arktype/commit/27b1d972e3fe5044571bd16508dd49ddee0d7592) Thanks [@ssalbdivad](https://github.com/ssalbdivad)! - temporarily disable narrowed numeric literal validation - -## 1.0.4-alpha - -### Patch Changes - -- [#660](https://github.com/arktypeio/arktype/pull/660) [`06760fd1`](https://github.com/arktypeio/arktype/commit/06760fd1a08227d2a477844a4709b8672ae37e0c) Thanks [@ssalbdivad](https://github.com/ssalbdivad)! - temporarily disable narrowed numeric literal validation - -## 1.0.3-alpha - -### Patch Changes - -- ddedc880: update Deno release - -## 1.0.2-alpha - -### Patch Changes - -- 0325e26d: rename api.ts entrypoints to main.ts to improve Deno compatibility - -## 1.0.1-alpha - -### Patch Changes - -- a871a43c: minor improvements to default problem messages - -## 1.0.0-alpha - -### Major Changes - -- 1f7658f8: release 1.0.0-alpha ⛵ - -### Minor Changes - -- 1f7658f8: allow adhoc problems via "mustBe" and "cases" codes -- 1f7658f8: add custom messages for validation keywords -- 1f7658f8: discriminated branches are now pruned to avoid redundant checks -- 1f7658f8: add config expressions, preserve configs during traversal -- 1f7658f8: add key traversal options for "distilled" and "strict" keys - -### Patch Changes - -- 1f7658f8: fix narrow tuple expression recursive inference -- 1f7658f8: add parsedNumber, parsedInteger validator keywords -- 1f7658f8: add Luhn Validation to creditCard keyword - -## 0.6.0 - -### Minor Changes - -- da4c2d63: allow adhoc problems via "mustBe" and "cases" codes -- da4c2d63: add custom messages for validation keywords -- da4c2d63: discriminated branches are now pruned to avoid redundant checks -- da4c2d63: add config expressions, preserve configs during traversal -- da4c2d63: add key traversal options for "distilled" and "strict" keys - -### Patch Changes - -- da4c2d63: fix narrow tuple expression recursive inference -- da4c2d63: add parsedNumber, parsedInteger validator keywords -- da4c2d63: add Luhn Validation to creditCard keyword - -## 0.5.1 - -### Patch Changes - -- 7a6d6504: fix narrow tuple expression recursive inference - -## 0.5.0 - -### Minor Changes - -- 285842e4: allow adhoc problems via "mustBe" and "cases" codes -- 285842e4: discriminated branches are now pruned to avoid redundant checks - -### Patch Changes - -- 285842e4: add parsedNumber, parsedInteger validator keywords -- 285842e4: add Luhn Validation to creditCard keyword - -## 0.4.0 - -### Minor Changes - -- 33682224: add expression helper functions (intersection, arrayOf, etc.) -- 33682224: include prototype keys in keyof types, align inference with TS keyof - -## 0.3.0 - -### Minor Changes - -- db9379ee: improve problem configs, make them available at type and scope levels -- db9379ee: add prerequisite props (props that must be valid for others to check) -- db9379ee: keep track of configs during traversal, query most specific relevant options -- db9379ee: fix return values for nested morphs -- db9379ee: infer keyof array types as `${number}` - -### Patch Changes - -- db9379ee: fix multi-part error message writers - -## 0.2.0 - -### Minor Changes - -- 37aa4054: improve problem configs, make them available at type and scope levels -- 37aa4054: keep track of configs during traversal, query most specific relevant options - -### Patch Changes - -- 37aa4054: fix multi-part error message writers - -## 0.1.4 - -### Patch Changes - -- 27f2ec8c: improve duplicate alias error messages for scope imports -- 27f2ec8c: add validation for keyof operands - -## 0.1.3 - -### Patch Changes - -- f3776be1: add new default jsObjects space -- f3776be1: replace subdomain with objectKind, allow configurable instanceof checks - -## 0.1.2 - -### Patch Changes - -- 6956bae: allow access to internal API through arktype/internal - -## 0.1.1 - -### Patch Changes - -- 3a0fa48: - include data in check results regardless of success - - fix morph inference within node definitions - -## 0.1.0 - -### Minor Changes - -- cad89ca: refactor: arktype 1.0 prerelease diff --git a/LICENSE b/LICENSE index 7d331efa52..bddd28836d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2022 ArkType +Copyright 2023 ArkType Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 349479e57a..ee65723f43 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

ArkType TypeScript's 1:1 validator

-[](https://arktype.io/try) +[](https://arktype.io/try) `typescript@4.9.5` in VS Code— no extensions or plugins required (how?) (try in-browser) @@ -8,160 +8,143 @@ ## What is it? - -

ArkType is a runtime validation library that can infer TypeScript definitions 1:1 and reuse them as highly-optimized validators for your data.

With each character you type, you'll get immediate feedback from your editor in the form of either a fully-inferred Type or a specific and helpful ParseError.

This result exactly mirrors what you can expect to happen at runtime down to the punctuation of the error message- no plugins required.

- - -```ts @blockFrom:dev/test/examples/type.ts -import { type } from "arktype" - -// Definitions are statically parsed and inferred as TS. -export const user = type({ - name: "string", - device: { - platform: "'android'|'ios'", - "version?": "number" - } -}) - -// Validators return typed data or clear, customizable errors. -export const { data, problems } = user({ - name: "Alan Turing", - device: { - // problems.summary: "device/platform must be 'android' or 'ios' (was 'enigma')" - platform: "enigma" - } -}) -``` - Check out [how it works](#how), [try it in-browser](https://arktype.io/docs/#your-first-type), or [scroll slightly](#install) to read about installation. - +## Install -## Install 📦`12KB` gzipped, `0` dependencies - -Npm Icon npm install arktype +Npm Icon npm install arktype (or whatever package manager you prefer)
-Our types are tested in [strict-mode](https://www.typescriptlang.org/tsconfig#strict) with TypeScript version `4.8` or greater. +Our types are tested in [strict-mode](https://www.typescriptlang.org/tsconfig#strict) with TypeScript version `5.4+`, although you will likely have success with other versions after 5.0. -_Our APIs have mostly stabilized, but details may still change during the alpha/beta stages of our 1.0 release. If you have suggestions that may require a breaking change, now is the time to let us know!_ ⛵ +If your types work but you notice errors in node_modules, this could be due to `tsconfig` incompatibilities- please enable `compilerOptions/skipLibCheck` ([docs](https://www.typescriptlang.org/tsconfig/#skipLibCheck)). - +## Your first type -### Scopes +Defining basic types in ArkType is just like TypeScript, so if you already know how to do that, congratulations! You already know most of ArkType's syntax 🎉 -[Try this example in-browser.](https://arktype.io/docs/scopes) +For an ever better in-editor developer experience, try the [ArkDark VSCode extension](https://marketplace.visualstudio.com/items?itemName=arktypeio.arkdark) for syntax highlighting. -```ts @blockFrom:dev/test/examples/scope.ts -import { scope } from "arktype" +```ts @blockFrom:dev/test/examples/type.ts +import { type } from "arktype" -// Scopes are collections of types that can reference each other. -export const types = scope({ - package: { - name: "string", - "dependencies?": "package[]", - "contributors?": "contributor[]" - }, - contributor: { - // Subtypes like 'email' are inferred like 'string' but provide additional validation at runtime. - email: "email", - "packages?": "package[]" - } -}).compile() +// Definitions are statically parsed and inferred as TS. +export const user = type({ + name: "string", + device: { + platform: "'android'|'ios'", + "version?": "number" + } +}) -// Cyclic types are inferred to arbitrary depth... -export type Package = typeof types.package.infer +// Validators return typed data or clear, customizable errors. +export const out = user({ + name: "Alan Turing", + device: { + // errors.summary: "device/platform must be 'android' or 'ios' (was 'enigma')" + platform: "enigma" + } +}) -// And can validate cyclic data. -const packageData: Package = { - name: "arktype", - dependencies: [{ name: "typescript" }], - contributors: [{ email: "david@sharktypeio" }] +if (out instanceof type.errors) { + // a clear, user-ready error message, even for complex unions and intersections + console.log(out.summary) +} else { + // your valid data! + console.log(out) } -packageData.dependencies![0].dependencies = [packageData] - -export const { data, problems } = types.package(packageData) ``` -## Syntax +## Example syntax -This is an informal, non-exhaustive list of current and upcoming ArkType syntax. - -There are some subjects it doesn't cover, primarily tuple expressions and scopes. As mentioned below, keep an eye out for comprehensive docs coming with the upcoming beta release. In the meantime, join [our Discord](https://discord.gg/xEzdc3fJQC) or head to our [GitHub Discussions](https://github.com/arktypeio/arktype/discussions) to ask a question and there's a good chance you'll see a response within the hour 😊 +Lots more docs are on the way, but I want to highlight some of the most useful syntax patterns/features that are carried over from alpha as well as those new to the 2.0 release. ```ts +// Syntax carried over from 1.0 + TS export const currentTsSyntax = type({ - keyword: "null", - stringLiteral: "'TS'", - numberLiteral: "5", - bigintLiteral: "5n", - union: "string|number", - intersection: "boolean&true", - array: "Date[]", - grouping: "(0|1)[]", - objectLiteral: { - nested: "string", - "optional?": "number" - }, - tuple: ["number", "number"] + keyword: "null", + stringLiteral: "'TS'", + numberLiteral: "5", + bigintLiteral: "5n", + union: "string|number", + intersection: "boolean&true", + array: "Date[]", + grouping: "(0|1)[]", + objectLiteral: { + nested: "string", + "optional?": "number" + }, + tuple: ["number", "number"] }) -// these features will be available in the upcoming release +// available syntax new to 2.0 export const upcomingTsSyntax = type({ - keyof: "keyof bigint", - thisKeyword: "this", // recurses to the root of the current type - variadicTuples: ["true", "...false[]"] + keyof: "keyof bigint", + variadicTuples: ["true", "...", "false[]"] }) +// runtime-specific syntax and builtin keywords with great error messages + export const validationSyntax = type({ - keywords: "email|uuid|creditCard|integer", // and many more - builtinParsers: "parsedDate", // parses a Date from a string - nativeRegexLiteral: /@arktype\.io/, - embeddedRegexLiteral: "email&/@arktype\\.io/", - divisibility: "number%10", // a multiple of 10 - bound: "alpha>10", // an alpha-only string with more than 10 characters - range: "1<=email[]<99", // a list of 1 to 99 emails - narrows: ["number", "=>", (n) => n % 2 === 1], // an odd integer - morphs: ["string", "|>", parseFloat] // validates a string input then parses it to a number + keywords: "email|uuid|creditCard|integer", // and many more + builtinParsers: "parse.date", // parses a Date from a string + nativeRegexLiteral: /@arktype\.io/, + embeddedRegexLiteral: "email&/@arktype\\.io/", + divisibility: "number%10", // a multiple of 10 + bound: "alpha>10", // an alpha-only string with more than 10 characters + range: "1<=email[]<100", // a list of 1 to 99 emails + narrows: ["number", ":", n => n % 2 === 1], // an odd integer + morphs: ["string", "=>", parseFloat] // validates a string input then parses it to a number }) -// in the upcoming release, you can use chaining to define expressions directly -// that use objects or functions that can't be embedded in strings +// root-level expressions -export const parseBigintLiteral = type({ value: "string" }) - .and({ - format: "'bigint'" - }) - .narrow((data): data is { value: `${string}n`; format: "bigint" } => - data.value.endsWith("n") - ) - .morph((data) => BigInt(data.value.slice(-1))) +const intersected = type({ value: "string" }, "&", { format: "'bigint'" }) -export const { data, problems } = parseBigintLiteral("999n") -// ^ bigint | undefined +// chained expressions via .or, .and, .narrow, .pipe and much more +// (these replace previous helper methods like union and intersection) + +const user = type({ + name: "string", + age: "number" +}) + +const parseUser = type("string").pipe(s => JSON.parse(s), user) + +// type is fully introspectable and traversable, displayed as: +type ParseUser = Type< + (In: string) => Out<{ + name: string + age: number + }> +> + +const maybeMe = parseUser('{ "name": "David" }') + +if (maybeMe instanceof type.errors) { + // "age must be a number (was missing)" + console.log(maybeMe.summary) +} ``` -## API +There's so much more I want to share but I want to get at least an initial version of the 2.0 branch merged tonight so look forward to that next week! - +## API ArkType supports many of TypeScript's built-in types and operators, as well as some new ones dedicated exclusively to runtime validation. In fact, we got a little ahead of ourselves and built a ton of cool features, but we're still working on getting caught up syntax and API docs. Keep an eye out for more in the next couple weeks ⛵ In the meantime, check out the examples here and use the type hints you get to learn how you can customize your types and scopes. If you want to explore some of the more advanced features, take a look at [our unit tests](./dev/test) or ask us [on Discord](https://discord.gg/xEzdc3fJQC) if your functionality is supported. If not, [create a GitHub issue](https://github.com/arktypeio/arktype/issues/new) so we can prioritize it! - - ## Integrations ### tRPC @@ -188,44 +171,38 @@ If you're curious, below is an example of what that looks like under the hood. I ```ts @blockFrom:src/parse/string/shift/operator/operator.ts:parseOperator export const parseOperator = (s: DynamicState): void => { - const lookahead = s.scanner.shift() - return lookahead === "" - ? s.finalize() - : lookahead === "[" - ? s.scanner.shift() === "]" - ? s.rootToArray() - : s.error(incompleteArrayTokenMessage) - : isKeyOf(lookahead, Scanner.branchTokens) - ? s.pushRootToBranch(lookahead) - : lookahead === ")" - ? s.finalizeGroup() - : isKeyOf(lookahead, Scanner.comparatorStartChars) - ? parseBound(s, lookahead) - : lookahead === "%" - ? parseDivisor(s) - : lookahead === " " - ? parseOperator(s) - : throwInternalError(writeUnexpectedCharacterMessage(lookahead)) + const lookahead = s.scanner.shift() + return ( + lookahead === "" ? s.finalize() + : lookahead === "[" ? + s.scanner.shift() === "]" ? + s.rootToArray() + : s.error(incompleteArrayTokenMessage) + : isKeyOf(lookahead, Scanner.branchTokens) ? s.pushRootToBranch(lookahead) + : lookahead === ")" ? s.finalizeGroup() + : isKeyOf(lookahead, Scanner.comparatorStartChars) ? + parseBound(s, lookahead) + : lookahead === "%" ? parseDivisor(s) + : lookahead === " " ? parseOperator(s) + : throwInternalError(writeUnexpectedCharacterMessage(lookahead)) + ) } export type parseOperator = - s["unscanned"] extends Scanner.shift - ? lookahead extends "[" - ? unscanned extends Scanner.shift<"]", infer nextUnscanned> - ? state.setRoot - : error - : lookahead extends Scanner.BranchToken - ? state.reduceBranch - : lookahead extends ")" - ? state.finalizeGroup - : lookahead extends Scanner.ComparatorStartChar - ? parseBound - : lookahead extends "%" - ? parseDivisor - : lookahead extends " " - ? parseOperator> - : error> - : state.finalize + s["unscanned"] extends Scanner.shift ? + lookahead extends "[" ? + unscanned extends Scanner.shift<"]", infer nextUnscanned> ? + state.setRoot + : error + : lookahead extends Scanner.BranchToken ? + state.reduceBranch + : lookahead extends ")" ? state.finalizeGroup + : lookahead extends Scanner.ComparatorStartChar ? + parseBound + : lookahead extends "%" ? parseDivisor + : lookahead extends " " ? parseOperator> + : error> + : state.finalize ``` ## Contributions diff --git a/ark/attest/README.md b/ark/attest/README.md new file mode 100644 index 0000000000..5ebc695c83 --- /dev/null +++ b/ark/attest/README.md @@ -0,0 +1,265 @@ +# Attest + +Attest is a testing library that makes your TypeScript types available at runtime, giving you access to precise type-level assertions and performance benchmarks. + +Assertions are framework agnostic and can be seamlessly integrated with your existing Vitest, Jest, or Mocha tests. + +Benchmarks can run from anywhere and will deterministically report the number of type instantiations contributed by the contents of the `bench` call. + +If you've ever wondered how [ArkType](https://github.com/arktypeio/arktype) can guarantee identical behavior between its runtime and static parser implementations and highly optimized editor performance, Attest is your answer⚡ + +## Installation + +```bash +npm install @arktype/attest +``` + +_Note: This package is still in alpha! Your feedback will help us iterate toward a stable 1.0._ + +## Setup + +To use attest's type assertions, you'll need to call our setup/cleanup methods before your first test and after your last test, respectively. This usually involves some kind of globalSetup/globalTeardown config. + +### Vitest + +`vitest.config.ts` + +```ts +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globalSetup: ["setupVitest.ts"] + } +}) +``` + +`setupVitest.ts` + +```ts +import { setup, teardown } from "@arktype/attest" + +// config options can be passed here +export const setup = () => setup({}) + +export const teardown = teardown +``` + +### Mocha + +`package.json` + +```json +"mocha": { + "require": "./setupMocha.ts" +} +``` + +`setupMocha.ts` + +```ts +import { setup, teardown } from "@arktype/attest" + +// config options can be passed here +export const mochaGlobalSetup = () => setup({}) + +export const mochaGlobalTeardown = teardown +``` + +You should also add `.attest` to your repository's `.gitignore` file. + +Bun support is currently pending a [bug in the way their source maps translate to stack traces](https://github.com/oven-sh/bun/issues/7120). If this is a problem for you, please 👍 that issue so they prioritize it! + +## Assertions + +Here are some simple examples of type assertions and snapshotting: + +```ts +// @arktype/attest assertions can be made from any unit test framework with a global setup/teardown +describe("attest features", () => { + it("type and value assertions", () => { + const even = type("number%2") + // asserts even.infer is exactly number + attest(even.infer) + // make assertions about types and values seamlessly + attest(even.infer).type.toString.snap("number") + // including object literals- no more long inline strings! + attest(even.json).snap({ + intersection: [{ domain: "number" }, { divisor: 2 }] + }) + }) + + it("error assertions", () => { + // Check type errors, runtime errors, or both at the same time! + // @ts-expect-error + attest(() => type("number%0")).throwsAndHasTypeError( + "% operator must be followed by a non-zero integer literal (was 0)" + ) + // @ts-expect-error + attest(() => type({ "[object]": "string" })).type.errors( + "Indexed key definition 'object' must be a string, number or symbol" + ) + }) + + it("completion snapshotting", () => { + // snapshot expected completions for any string literal! + // @ts-expect-error (if your expression would throw, prepend () =>) + attest(() => type({ a: "a", b: "b" })).completions({ + a: ["any", "alpha", "alphanumeric"], + b: ["bigint", "boolean"] + }) + type Legends = { faker?: "🐐"; [others: string]: unknown } + // works for keys or index access as well (may need prettier-ignore to avoid removing quotes) + // prettier-ignore + attest({ "f": "🐐" } as Legends).completions({ "f": ["faker"] }) + }) + + it("integrate runtime logic with type assertions", () => { + const arrayOf = type("", "t[]") + const numericArray = arrayOf("number | bigint") + // flexibly combine runtime logic with type assertions to customize your + // tests beyond what is possible from pure static-analysis based type testing tools + if (getTsVersionUnderTest().startsWith("5")) { + // this assertion will only occur when testing TypeScript 5+! + attest<(number | bigint)[]>(numericArray.infer) + } + }) +}) +``` + +## Benches + +Benches are run separately from tests and don't require any special setup. If the below file was `benches.ts`, you could run it using something like `tsx benches.ts` or `ts-node benches.ts`: + +```ts +// Combinatorial template literals often result in expensive types- let's benchmark this one! +type makeComplexType = + s extends `${infer head}${infer tail}` ? head | tail | makeComplexType + : s + +bench("bench type", () => { + return {} as makeComplexType<"defenestration"> + // This is an inline snapshot that will be populated or compared when you run the file +}).types([169, "instantiations"]) + +bench( + "bench runtime and type", + () => { + return {} as makeComplexType<"antidisestablishmentarianism"> + }, + fakeCallOptions +) + // Average time it takes the function execute + .mean([2, "ms"]) + // Seems like our type is O(n) with respect to the length of the input- not bad! + .types([337, "instantiations"]) +``` + +## CLI + +Attest also includes a builtin `attest` CLI including the following commands: + +### `stats` + +```bash +npm run attest stats packages/* +``` + +Summarizes key type performance metrics for each package (check time, instantiations, and type count). + +Expects any number of args representing package directories to check, optionally specified using glob patterns like `packages/*`. + +If no directories are provided, defaults to CWD. + +### `trace` + +```bash +npm run attest trace . +``` + +Creates a trace.json file in a .tstrace directory that can be viewed as a type performance heat map via a tool like https://ui.perfetto.dev/. Also summarizes any hot spots as identified by `@typescript/analyze-trace`. + +Trace expects a single argument representing the root directory of the root package for which to gather type information. + +## Integration + +### Setup + +If you're a library author wanting to integrate type into your own assertions instead of using the `attest` API, you'll need to call `setup` with a list of `attestAliases` to ensure type data is collected from your own functions: + +```ts +// attest will only collect type data from functions with names listed in `attestAliases` +setup({ attestAliases: ["yourCustomAssert"] }) + +// There are many other config options, but some are primarily internal- use others at your own risk! +``` + +You'll need to make sure that setup with whatever aliases you need before the first test runs. As part of the setup process, attest will search for the specified assertion calls and cache their types in a temporary file that will be referenced during test execution. + +This ensures that type assertions can be made across processes without creating a new TSServer instance for each. + +### TS Versions + +There is a tsVersions setting that allows testing multiple TypeScript aliases at once. + +````ts globalSetup.ts +import { setup } from "@arktype/attest" +/** A string or list of strings representing the TypeScript version aliases to run. + * + * Aliases must be specified as a package.json dependency or devDependency beginning with "typescript". + * Alternate aliases can be specified using the "npm:" prefix: + * ```json + * "typescript": "latest", + * "typescript-next": "npm:typescript@next", + * "typescript-1": "npm:typescript@5.2" + * "typescript-2": "npm:typescript@5.1" + * ``` + * + * "*" can be pased to run all discovered versions beginning with "typescript". + */ +setup({ tsVersions: "*" }) +```` + +### APIs + +The most flexible attest APIs are `getTypeAssertionsAtPosition` and `caller`. + +Here's an example of how you might use them in your own API: + +```ts +import { getTypeAssertionsAtPosition, caller } from "@arktype/attest" + +const yourCustomAssert = (actualValue: expectedType) => { + const position = caller() + const types = getTypeAssertionsAtPosition(position) + // assert that the type of actualValue is the same as the type of expectedType + const relationship = types[0].args[0].relationships.typeArgs[0] + if (relationship === undefined) { + throw new Error( + `yourCustomAssert requires a type arg representing the expected type, e.g. 'yourCustomAssert<"foo">("foo")'` + ) + } + if (relationship !== "equality") { + throw new Error( + `Expected ${types.typeArgs[0].type}, got ${types.args[0].type} with relationship ${relationship}` + ) + } +} +``` + +A user might then use `yourCustomAssert` like this: + +```ts +import { yourCustomAssert } from "your-package" + +test("my code", () => { + // Ok + yourCustomAssert<"foo">(`${"f"}oo` as const) + // Error: `Expected boolean, got true with relationship subtype` + yourCustomAssert(true) + // Error: `Expected 5, got number with relationship supertype` + yourCustomAssert<5>(2 + 3) +}) +``` + +Please don't hesitate to a GitHub [issue](https://github.com/arktypeio/arktype/issues/new/choose) or [discussion](https://github.com/arktypeio/arktype/discussions/new/choose) or reach out on [ArkType's Discord](https://arktype.io/discord) if you have any questions or feedback- we'd love to hear from you! ⛵ diff --git a/ark/attest/__tests__/assertions.test.ts b/ark/attest/__tests__/assertions.test.ts new file mode 100644 index 0000000000..851972f735 --- /dev/null +++ b/ark/attest/__tests__/assertions.test.ts @@ -0,0 +1,130 @@ +import { attest } from "@arktype/attest" +import * as assert from "node:assert/strict" + +const o = { ark: "type" } + +specify(() => { + it("type parameter", () => { + attest<{ ark: string }>(o) + assert.throws( + // @ts-expect-error + () => attest<{ ark: "type" }>(o), + assert.AssertionError, + "type" + ) + }) + + it("type-only assertion", () => { + attest<{ ark: string }, typeof o>() + assert.throws( + // @ts-expect-error + () => attest<{ ark: "type" }, typeof o>(), + assert.AssertionError, + "type" + ) + }) + + it("type toString", () => { + attest(o).type.toString("{ ark: string; }") + attest(o).type.toString.is("{ ark: string; }") + }) + + it("equals", () => { + attest(o).equals({ ark: "type" }) + }) + + it("object", () => { + attest<{ i: string }>({ i: "love my wife" }) + assert.throws( + // @ts-expect-error + () => attest<{ g: string }>({ g: "whiz" as unknown }), + assert.AssertionError, + "unknown" + ) + }) + + it("typed allows equivalent types", () => { + const actual = { a: true, b: false } + attest<{ + b: boolean + a: boolean + }>(actual) + }) + + it("functional asserts don't exist on pure value types", () => { + // @ts-expect-error + attest(5).throws + }) + + it("not equal", () => { + assert.throws( + () => attest(o).equals({ ark: "typo" }), + assert.AssertionError, + "type !== typo" + ) + }) + + it("instanceOf", () => { + const d = new Date() + attest(d).instanceOf(Date) + assert.throws(() => attest(d).instanceOf(RegExp), assert.AssertionError) + }) + + it("incorrect type", () => { + assert.throws( + // @ts-expect-error + () => attest<{ re: number }>(o), + assert.AssertionError, + "o is not of type number" + ) + }) + + it("any type", () => { + attest(o as any) + assert.throws( + () => attest({} as unknown), + assert.AssertionError, + "unknown" + ) + }) + + it("assert unknown ignores type", () => { + const myValue = { a: ["+"] } as const + const myExpectedValue = { a: ["+"] } + // @ts-expect-error + attest(myValue).equals(myExpectedValue) + attest(myValue).unknown.equals(myExpectedValue) + assert.throws( + () => attest(myValue).unknown.is(myExpectedValue), + assert.AssertionError, + "not reference-equal" + ) + }) + + it("multiline", () => { + attest({ + several: true, + lines: true, + long: true + } as object) + assert.throws( + () => + attest({ + several: true, + lines: true, + long: true + }), + assert.AssertionError + ) + }) + + it("nonexistent types always fail", () => { + // @ts-expect-error + const nonexistent: NonExistent = {} + assert.throws( + () => attest<{ something: "specific" }>(nonexistent), + assert.AssertionError, + "specific" + ) + }) +}) diff --git a/ark/attest/__tests__/benchExpectedOutput.ts b/ark/attest/__tests__/benchExpectedOutput.ts new file mode 100644 index 0000000000..c3c0240738 --- /dev/null +++ b/ark/attest/__tests__/benchExpectedOutput.ts @@ -0,0 +1,56 @@ +import { bench } from "@arktype/attest" +import type { makeComplexType as externalmakeComplexType } from "./utils.js" + +const fakeCallOptions = { + until: { count: 2 }, + fakeCallMs: "count", + benchFormat: { noExternal: true } +} + +bench( + "bench call single stat median", + () => { + return "boofoozoo".includes("foo") + }, + fakeCallOptions +).median([2, "ms"]) + +bench( + "bench call single stat", + () => { + return "boofoozoo".includes("foo") + }, + fakeCallOptions +).mean([2, "ms"]) + +bench( + "bench call mark", + () => { + return /.*foo.*/.test("boofoozoo") + }, + fakeCallOptions +).mark({ mean: [2, "ms"], median: [2, "ms"] }) + +type makeComplexType = + S extends `${infer head}${infer tail}` ? head | tail | makeComplexType + : S + +bench("bench type", () => { + return {} as makeComplexType<"defenestration"> +}).types([177, "instantiations"]) + +bench("bench type from external module", () => { + return {} as externalmakeComplexType<"defenestration"> +}).types([193, "instantiations"]) + +bench( + "bench call and type", + () => { + return {} as makeComplexType<"antidisestablishmentarianism"> + }, + fakeCallOptions +) + .mean([2, "ms"]) + .types([345, "instantiations"]) + +bench("empty", () => {}).types([0, "instantiations"]) diff --git a/ark/attest/__tests__/benchTemplate.ts b/ark/attest/__tests__/benchTemplate.ts new file mode 100644 index 0000000000..077d49a7ff --- /dev/null +++ b/ark/attest/__tests__/benchTemplate.ts @@ -0,0 +1,56 @@ +import { bench } from "@arktype/attest" +import type { makeComplexType as externalmakeComplexType } from "./utils.js" + +const fakeCallOptions = { + until: { count: 2 }, + fakeCallMs: "count", + benchFormat: { noExternal: true } +} + +bench( + "bench call single stat median", + () => { + return "boofoozoo".includes("foo") + }, + fakeCallOptions +).median() + +bench( + "bench call single stat", + () => { + return "boofoozoo".includes("foo") + }, + fakeCallOptions +).mean() + +bench( + "bench call mark", + () => { + return /.*foo.*/.test("boofoozoo") + }, + fakeCallOptions +).mark() + +type makeComplexType = + S extends `${infer head}${infer tail}` ? head | tail | makeComplexType + : S + +bench("bench type", () => { + return {} as makeComplexType<"defenestration"> +}).types() + +bench("bench type from external module", () => { + return {} as externalmakeComplexType<"defenestration"> +}).types() + +bench( + "bench call and type", + () => { + return {} as makeComplexType<"antidisestablishmentarianism"> + }, + fakeCallOptions +) + .mean() + .types() + +bench("empty", () => {}).types() diff --git a/ark/attest/__tests__/completions.test.ts b/ark/attest/__tests__/completions.test.ts new file mode 100644 index 0000000000..17eb607425 --- /dev/null +++ b/ark/attest/__tests__/completions.test.ts @@ -0,0 +1,69 @@ +import { attest, contextualize } from "@arktype/attest" +import { hasDomain } from "@arktype/util" +import assert from "assert" + +type Obj = { + prop1: string + prop2: string + extra: unknown +} +const obj: Obj = { prop1: "", prop2: "", extra: "" } + +type Ark = { + ark: "type" +} + +type Arks = { + ark: "string" | "semver" | "symbol" +} + +contextualize(() => { + it("quote types", () => { + // @ts-expect-error + attest({ ark: "" } as Ark).completions({ "": ["type"] }) + // prettier-ignore + // @ts-expect-error + attest({ ark: "t" } as Ark).completions({ t: ["type"] }) + //@ts-expect-error + attest({ ark: "ty" } as Ark).completions({ ty: ["type"] }) + }) + + it(".type.completions", () => { + //@ts-expect-error + attest({ ark: "s" } as Arks).type.completions({ + s: ["string", "symbol", "semver"] + }) + }) + + it("keys", () => { + //@ts-expect-error + attest({ "": "data" } as Obj).completions({ + "": ["extra", "prop1", "prop2"] + }) + }) + + it("index access", () => { + //@ts-expect-error + attest(() => obj["p"]).type.completions({ + p: ["prop1", "prop2"] + }) + }) + + it("duplicate string error", () => { + assert.throws( + () => attest({ "": "" }).type.completions({}), + Error, + "multiple completion candidates" + ) + }) + + it("empty", () => { + attest("").completions({}) + }) + + it("external package", () => { + hasDomain({}, "object") + // @ts-expect-error + attest(() => hasDomain({}, "b")).completions + }) +}) diff --git a/ark/attest/__tests__/demo.test.ts b/ark/attest/__tests__/demo.test.ts new file mode 100644 index 0000000000..a00a45598e --- /dev/null +++ b/ark/attest/__tests__/demo.test.ts @@ -0,0 +1,93 @@ +import { + attest, + contextualize, + getPrimaryTsVersionUnderTest +} from "@arktype/attest" +import { type } from "arktype" + +const o = { ark: "type" } as const +const shouldThrow = (a: false) => { + if (a) throw new Error(`${a} is not assignable to false`) +} + +contextualize(() => { + it("value snap", () => { + attest(o).snap({ ark: "type" }) + }) + + it("type snap", () => { + attest(o).type.toString.snap('{ readonly ark: "type"; }') + }) + + it("type assertion", () => { + attest<{ readonly ark: "type" }>(o) + }) + + it("type-only assertion", () => { + attest<{ readonly ark: "type" }, typeof o>() + }) + + it("chained snaps", () => { + attest(o) + .snap({ ark: "type" }) + .type.toString.snap('{ readonly ark: "type"; }') + }) + + it("error and type error snap", () => { + // @ts-expect-error + attest(() => shouldThrow(true)) + .throws.snap("Error: true is not assignable to false") + .type.errors.snap( + "Argument of type 'true' is not assignable to parameter of type 'false'." + ) + }) + + // @arktype/attest assertions can be made from any unit test framework with a global setup/teardown + + it("type and value assertions", () => { + const even = type("number%2") + // asserts even.infer is exactly number + attest(even.infer) + // make assertions about types and values seamlessly + attest(even.infer).type.toString.snap("number") + // including object literals- no more long inline strings! + attest(even.json).snap({ domain: "number", divisor: 2 }) + }) + + it("error assertions", () => { + // Check type errors, runtime errors, or both at the same time! + // @ts-expect-error + attest(() => type("number%0")).throwsAndHasTypeError( + "% operator must be followed by a non-zero integer literal (was 0)" + ) + // @ts-expect-error + attest(() => type({ "[object]": "string" })).type.errors( + "Indexed key definition 'object' must be a string, number or symbol" + ) + }) + + it("completion snapshotting", () => { + // snapshot expected completions for any string literal! + // @ts-expect-error (if your expression would throw, prepend () =>) + attest(() => type({ a: "a", b: "b" })).completions({ + a: ["any", "alpha", "alphanumeric"], + b: ["bigint", "boolean"] + }) + type Legends = { faker?: "🐐"; [others: string]: unknown } + // works for keys or index access as well (may need prettier-ignore to + // avoid removing quotes) + // prettier-ignore + attest({ f: "🐐" } as Legends).completions({ f: ["faker"] }) + }) + + it("integrate runtime logic with type assertions", () => { + const arrayOf = type("", "t[]") + const numericArray = arrayOf("number | bigint") + // flexibly combine runtime logic with type assertions to customize your + // tests beyond what is possible from pure static-analysis based type testing tools + if (getPrimaryTsVersionUnderTest().startsWith("5")) { + // this assertion will only occur when testing TypeScript 5+! + attest<(number | bigint)[]>(numericArray.infer) + } + }) +}) diff --git a/ark/attest/__tests__/externalSnapshots.test.ts b/ark/attest/__tests__/externalSnapshots.test.ts new file mode 100644 index 0000000000..b3df03356b --- /dev/null +++ b/ark/attest/__tests__/externalSnapshots.test.ts @@ -0,0 +1,98 @@ +import { attest, contextualize } from "@arktype/attest" +import { dirName, readJson, writeJson } from "@arktype/fs" +import * as assert from "node:assert/strict" +import { rmSync } from "node:fs" +import { join } from "node:path" +import { attestInternal } from "../assert/attest.js" +const testDir = dirName() +const testFile = "externalSnapshots.test.ts" +const o = { re: "do" } + +const defaultFileName = "assert.snapshots.json" +const defaultSnapPath = join(testDir, defaultFileName) +const defaultSnapFileContents = { + [testFile]: { + toFile: { + re: "do" + }, + toFileUpdate: { + re: "oldValue" + } + } +} + +const customFileName = "custom.snapshots.json" +const customSnapPath = join(testDir, customFileName) +const defaultSnapContentsAtCustomPath = { + [testFile]: { + toCustomFile: { re: "do" } + } +} + +beforeEach(() => { + writeJson(defaultSnapPath, defaultSnapFileContents) + writeJson(customSnapPath, defaultSnapContentsAtCustomPath) +}) + +afterEach(() => { + rmSync(defaultSnapPath, { force: true }) + rmSync(customSnapPath, { force: true }) +}) + +contextualize(() => { + it("create", () => { + attest(o).snap.toFile("toFile") + assert.throws( + () => attest({ re: "kt" }).snap.toFile("toFile"), + assert.AssertionError, + "kt" + ) + attest(1337).snap.toFile("toFileNew") + const contents = readJson(defaultSnapPath) + attest(contents).equals({ + [testFile]: { + ...defaultSnapFileContents[testFile], + toFileNew: 1337 + } + }) + }) + + it("update existing", () => { + attestInternal( + { re: "dew" }, + { cfg: { updateSnapshots: true } } + ).snap.toFile("toFileUpdate") + const updatedContents = readJson(defaultSnapPath) + const expectedContents = { + [testFile]: { + ...defaultSnapFileContents[testFile], + toFileUpdate: { re: "dew" } + } + } + assert.deepEqual(updatedContents, expectedContents) + }) + + it("with path", () => { + attest(o).snap.toFile("toCustomFile", { + path: customFileName + }) + assert.throws( + () => + attest({ re: "kt" }).snap.toFile("toCustomFile", { + path: customFileName + }), + assert.AssertionError, + "kt" + ) + attest(null).snap.toFile("toCustomFileNew", { + path: customFileName + }) + const contents = readJson(customSnapPath) + attest(contents).equals({ + [testFile]: { + ...defaultSnapContentsAtCustomPath[testFile], + toCustomFileNew: null + } + }) + }) +}) diff --git a/ark/attest/__tests__/functions.test.ts b/ark/attest/__tests__/functions.test.ts new file mode 100644 index 0000000000..a05c200940 --- /dev/null +++ b/ark/attest/__tests__/functions.test.ts @@ -0,0 +1,117 @@ +import { attest, contextualize } from "@arktype/attest" +import { fileName } from "@arktype/fs" +import * as assert from "node:assert/strict" +import { basename } from "node:path" + +const n = 5 +const o = { re: "do" } + +const shouldThrow = (a: false) => { + if (a) throw new Error(`${a} is not assignable to false`) +} + +const throwError = () => { + throw new Error("Test error.") +} + +contextualize(() => { + it("valid type errors", () => { + // @ts-expect-error + attest(o.re.length.nonexistent).type.errors( + /Property 'nonexistent' does not exist on type 'number'/ + ) + attest(o).type.errors("") + // @ts-expect-error + attest(() => shouldThrow(5, "")).type.errors.is( + "Expected 1 arguments, but got 2." + ) + }) + + it("bad type errors", () => { + assert.throws( + () => attest(o).type.errors(/This error doesn't exist/), + assert.AssertionError, + "doesn't exist" + ) + assert.throws( + () => + attest(() => + // @ts-expect-error + shouldThrow("this is a type error") + ).type.errors.is(""), + assert.AssertionError, + "not assignable" + ) + }) + + it("chainable", () => { + attest<{ re: string }>(o).equals({ re: "do" }) + // @ts-expect-error + attest(() => throwError("this is a type error")) + .throws("Test error.") + .type.errors("Expected 0 arguments, but got 1.") + }) + + it("bad chainable", () => { + assert.throws( + () => + attest(n) + .equals(5) + .type.errors.equals("Expecting an error here will throw"), + assert.AssertionError, + "Expecting an error" + ) + assert.throws( + () => attest(n).is(7).type.toString("string"), + assert.AssertionError, + "7" + ) + }) + + it("throwsAndHasTypeError", () => { + // @ts-expect-error + attest(() => shouldThrow(true)).throwsAndHasTypeError( + /true[\S\s]*not assignable[\S\s]*false/ + ) + // No thrown error + assert.throws( + () => + // @ts-expect-error + attest(() => shouldThrow(null)).throwsAndHasTypeError("not assignable"), + assert.AssertionError, + "didn't throw" + ) + // No type error + assert.throws( + () => + attest(() => shouldThrow(true as any)).throwsAndHasTypeError( + "not assignable" + ), + assert.AssertionError, + "not assignable" + ) + }) + + it("throws empty", () => { + attest(throwError).throws() + assert.throws( + () => attest(() => shouldThrow(false)).throws(), + assert.AssertionError, + "didn't throw" + ) + }) + + const getThrownError = (f: () => void) => { + try { + f() + } catch (e) { + if (e instanceof Error) return e + } + throw new Error("Expected function to throw an error.") + } + + it("stack starts from test file", () => { + const e = getThrownError(() => attest(1 + 1).equals(3)) + assert.match(e.stack!.split("\n")[1], new RegExp(basename(fileName()))) + }) +}) diff --git a/ark/attest/__tests__/snap.test.ts b/ark/attest/__tests__/snap.test.ts new file mode 100644 index 0000000000..e2efff505f --- /dev/null +++ b/ark/attest/__tests__/snap.test.ts @@ -0,0 +1,83 @@ +import { attest, contextualize } from "@arktype/attest" +import * as assert from "node:assert/strict" + +const o = { re: "do" } +const shouldThrow = (a: false) => { + if (a) throw new Error(`${a} is not assignable to false`) +} +const throwError = () => { + throw new Error("Test error.") +} + +contextualize(() => { + it("default serializer doesn't care about prop order", () => { + const actual = { a: true, b: false } + attest(actual).snap({ b: false, a: true }) + }) + + it("snap", () => { + attest(o).snap({ re: `do` }) + attest(o).equals({ re: "do" }).type.toString.snap(`{ re: string; }`) + assert.throws( + () => attest(o).snap({ re: `dorf` }), + assert.AssertionError, + "dorf" + ) + }) + + it("value and type snap", () => { + attest(o).snap({ re: `do` }).type.toString.snap(`{ re: string; }`) + assert.throws( + () => attest(o).snap({ re: `do` }).type.toString.snap(`{ re: number; }`), + assert.AssertionError, + "number" + ) + }) + + it("error and type error snap", () => { + // @ts-expect-error + attest(() => shouldThrow(true)) + .throws.snap(`Error: true is not assignable to false`) + .type.errors.snap( + `Argument of type 'true' is not assignable to parameter of type 'false'.` + ) + assert.throws( + () => + // @ts-expect-error + attest(() => shouldThrow(1)) + .throws.snap(`Error: 1 is not assignable to false`) + .type.errors.snap( + `Argument of type '2' is not assignable to parameter of type 'false'.` + ), + assert.AssertionError, + "'2'" + ) + }) + + it("throws", () => { + attest(throwError).throws(/error/g) + assert.throws( + // Snap should never be populated + () => attest(() => shouldThrow(false)).throws.snap(), + assert.AssertionError, + "didn't throw" + ) + }) + /* + * Some TS errors as formatted as diagnostic "chains" + * We represent them by joining the parts of the message with newlines + */ + it("TS diagnostic chain", () => { + // @ts-expect-error + attest(() => shouldThrow({} as {} | false)).type.errors.snap( + `Argument of type 'false | {}' is not assignable to parameter of type 'false'.Type '{}' is not assignable to type 'false'.` + ) + }) + + it("multiple inline snaps", () => { + attest("firstLine\nsecondLine").snap(`firstLine +secondLine`) + attest("firstLine\nsecondLine").snap(`firstLine +secondLine`) + }) +}) diff --git a/ark/attest/__tests__/snapExpectedOutput.ts b/ark/attest/__tests__/snapExpectedOutput.ts new file mode 100644 index 0000000000..404aee2e4f --- /dev/null +++ b/ark/attest/__tests__/snapExpectedOutput.ts @@ -0,0 +1,26 @@ +import { attest, cleanup, setup } from "@arktype/attest" + +setup() + +attest({ re: "do" }).equals({ re: "do" }).type.toString.snap(`{ re: string; }`) + +attest(5).snap(5) + +attest({ re: "do" }).snap({ re: `do` }) + +// @ts-expect-error (using internal updateSnapshots hook) +attest({ re: "dew" }, { updateSnapshots: true }).snap({ re: `dew` }) + +// @ts-expect-error (using internal updateSnapshots hook) +attest(5, { updateSnapshots: true }).snap(5) + +attest(undefined).snap(`(undefined)`) + +attest({ a: undefined }).snap({ a: `(undefined)` }) + +attest("multiline\nmultiline").snap(`multiline +multiline`) + +attest("with `quotes`").snap(`with \`quotes\``) + +cleanup() diff --git a/ark/attest/__tests__/snapPopulation.test.ts b/ark/attest/__tests__/snapPopulation.test.ts new file mode 100644 index 0000000000..bedced28a0 --- /dev/null +++ b/ark/attest/__tests__/snapPopulation.test.ts @@ -0,0 +1,22 @@ +import { contextualize } from "@arktype/attest" +import { fromHere, readFile } from "@arktype/fs" +import { equal } from "node:assert/strict" +import { runThenGetContents } from "./utils.js" + +contextualize(() => { + it("bench populates file", () => { + const actual = runThenGetContents(fromHere("benchTemplate.ts")) + const expectedOutput = readFile( + fromHere("benchExpectedOutput.ts") + ).replaceAll("\r\n", "\n") + equal(actual, expectedOutput) + }) + + it("snap populates file", () => { + const actual = runThenGetContents(fromHere("snapTemplate.ts")) + const expectedOutput = readFile( + fromHere("snapExpectedOutput.ts") + ).replaceAll("\r\n", "\n") + equal(actual, expectedOutput) + }) +}) diff --git a/ark/attest/__tests__/snapTemplate.ts b/ark/attest/__tests__/snapTemplate.ts new file mode 100644 index 0000000000..bcabe87801 --- /dev/null +++ b/ark/attest/__tests__/snapTemplate.ts @@ -0,0 +1,25 @@ +import { attest, cleanup, setup } from "@arktype/attest" + +setup() + +attest({ re: "do" }).equals({ re: "do" }).type.toString.snap() + +attest(5).snap() + +attest({ re: "do" }).snap() + +// @ts-expect-error (using internal updateSnapshots hook) +attest({ re: "dew" }, { updateSnapshots: true }).snap() + +// @ts-expect-error (using internal updateSnapshots hook) +attest(5, { updateSnapshots: true }).snap(6) + +attest(undefined).snap() + +attest({ a: undefined }).snap() + +attest("multiline\nmultiline").snap() + +attest("with `quotes`").snap() + +cleanup() diff --git a/ark/attest/__tests__/utils.ts b/ark/attest/__tests__/utils.ts new file mode 100644 index 0000000000..0e1bd2dd7c --- /dev/null +++ b/ark/attest/__tests__/utils.ts @@ -0,0 +1,20 @@ +import { readFile, shell } from "@arktype/fs" +import { copyFileSync, rmSync } from "node:fs" + +export const runThenGetContents = (templatePath: string): string => { + const tempPath = templatePath + ".temp.ts" + copyFileSync(templatePath, tempPath) + try { + shell(`pnpm tsx ${tempPath}`) + } catch (e) { + console.error(e) + } + const resultContents = readFile(tempPath) + rmSync(tempPath) + return resultContents +} + +// type is used in benchTemplate.ts to test compatibility with external modules +export type makeComplexType = + S extends `${infer head}${infer tail}` ? head | tail | makeComplexType + : S diff --git a/ark/attest/assert/assertions.ts b/ark/attest/assert/assertions.ts new file mode 100644 index 0000000000..2fcbe3ee46 --- /dev/null +++ b/ark/attest/assert/assertions.ts @@ -0,0 +1,165 @@ +import { printable, throwInternalError } from "@arktype/util" +import { AssertionError } from "node:assert" +import * as assert from "node:assert/strict" +import type { TypeAssertionData } from "../cache/writeAssertionCache.js" +import type { AssertionContext } from "./attest.js" + +export type ThrowAssertionErrorContext = { + message: string + expected?: unknown + actual?: unknown + ctx: AssertionContext +} + +export const throwAssertionError = ({ + ctx, + ...errorArgs +}: ThrowAssertionErrorContext): never => { + const e = new assert.AssertionError(errorArgs) + e.stack = ctx.assertionStack + throw e +} + +export type AssertFn = ( + expected: unknown, + actual: unknown, + ctx: AssertionContext +) => void + +export type MappedTypeAssertionResult = { + actual: unknown + expected?: unknown +} | null + +export class TypeAssertionMapping { + constructor( + public fn: ( + data: TypeAssertionData, + ctx: AssertionContext + ) => MappedTypeAssertionResult + ) {} +} + +export const versionableAssertion = + (fn: AssertFn): AssertFn => + (expected, actual, ctx) => { + if (actual instanceof TypeAssertionMapping) { + if (!ctx.typeAssertionEntries) { + throwInternalError( + `Unexpected missing typeAssertionEntries when passed a TypeAssertionMapper` + ) + } + for (const [version, data] of ctx.typeAssertionEntries!) { + let errorMessage = "" + try { + const mapped = actual.fn(data, ctx) + if (mapped !== null) { + fn( + "expected" in mapped ? mapped.expected : expected, + mapped.actual, + ctx + ) + } + } catch (e) { + errorMessage += `❌TypeScript@${version}:${e}\n` + } + if (errorMessage) throw new AssertionError({ message: errorMessage }) + } + } else fn(expected, actual, ctx) + } + +const unversionedAssertEquals: AssertFn = (expected, actual, ctx) => { + if (expected === actual) return + + if (typeof expected === "object" && typeof actual === "object") { + try { + assert.deepStrictEqual(actual, expected) + } catch (e: any) { + e.stack = ctx.assertionStack + throw e + } + } else { + // some nonsense to get a good stack trace + try { + assert.strictEqual(actual, expected) + } catch (e: any) { + e.stack = ctx.assertionStack + throw e + } + } +} + +export const assertEquals = versionableAssertion(unversionedAssertEquals) + +export const typeEqualityMapping = new TypeAssertionMapping(data => { + const expected = data.typeArgs[0] + const actual = data.typeArgs[1] ?? data.args[0] + if (!expected || !actual) + throwInternalError(`Unexpected type data ${printable(data)}`) + + if (actual.relationships.typeArgs[0] !== "equality") { + return { + expected: expected.type, + actual: + expected.type === actual.type ? + "(serializes to same value)" + : actual.type + } + } + return null +}) + +export const assertEqualOrMatching = versionableAssertion( + (expected, actual, ctx) => { + const assertionArgs = { actual, expected, ctx } + if (typeof actual !== "string") { + throwAssertionError({ + message: `Value was of type ${typeof actual} (expected a string).`, + ...assertionArgs + }) + } else if (typeof expected === "string") { + if (!actual.includes(expected)) { + throwAssertionError({ + message: `Expected string '${expected}' did not appear in actual string '${actual}'.`, + ...assertionArgs + }) + } + } else if (expected instanceof RegExp) { + if (!expected.test(actual)) { + throwAssertionError({ + message: `Actual string '${actual}' did not match regex '${expected.source}'.`, + ...assertionArgs + }) + } + } else { + throw new Error( + `Expected value for this assertion should be a string or RegExp.` + ) + } + } +) + +export type AssertedFnCallResult = { + returned?: unknown + threw?: string +} +export const getThrownMessage = ( + result: AssertedFnCallResult, + ctx: AssertionContext +): string | undefined => { + if (!("threw" in result)) + throwAssertionError({ message: "Function didn't throw.", ctx }) + + return result.threw +} +export const callAssertedFunction = ( + asserted: Function +): AssertedFnCallResult => { + const result: AssertedFnCallResult = {} + try { + result.returned = asserted() + } catch (error) { + result.threw = String(error) + } + return result +} diff --git a/ark/attest/assert/attest.ts b/ark/attest/assert/attest.ts new file mode 100644 index 0000000000..ec26920954 --- /dev/null +++ b/ark/attest/assert/attest.ts @@ -0,0 +1,69 @@ +import { caller, getCallStack, type SourcePosition } from "@arktype/fs" +import type { inferTypeRoot, validateTypeRoot } from "arktype" +import { + getTypeAssertionsAtPosition, + type VersionedTypeAssertion +} from "../cache/getCachedAssertions.js" +import { getConfig, type AttestConfig } from "../config.js" +import { assertEquals, typeEqualityMapping } from "./assertions.js" +import { + ChainableAssertions, + type AssertionKind, + type rootAssertions +} from "./chainableAssertions.js" + +export type AttestFn = { + ( + ...args: [actual] extends [never] ? [value: expected] : [] + ): [expected] extends [never] ? rootAssertions + : rootAssertions + ( + actual: actual, + def: validateTypeRoot + ): asserts actual is unknown extends actual ? inferTypeRoot & actual + : Extract> +} + +export type AssertionContext = { + actual: unknown + originalAssertedValue: unknown + cfg: AttestConfig + allowRegex: boolean + position: SourcePosition + defaultExpected?: unknown + assertionStack: string + typeAssertionEntries?: VersionedTypeAssertion[] + lastSnapName?: string +} + +export type InternalAssertionHooks = { + [k in keyof AssertionContext]?: k extends "cfg" ? Partial + : AssertionContext[k] +} + +export const attestInternal = ( + value?: unknown, + { cfg: cfgHooks, ...ctxHooks }: InternalAssertionHooks = {} +): ChainableAssertions => { + const position = caller() + const cfg = { ...getConfig(), ...cfgHooks } + const ctx: AssertionContext = { + actual: value, + allowRegex: false, + originalAssertedValue: value, + position, + cfg, + assertionStack: getCallStack({ offset: 1 }).join("\n"), + ...ctxHooks + } + if (!cfg.skipTypes) { + ctx.typeAssertionEntries = getTypeAssertionsAtPosition(position) + if (ctx.typeAssertionEntries[0]?.[1].typeArgs[0]) { + // if there is an expected type arg, check it immediately + assertEquals(undefined, typeEqualityMapping, ctx) + } + } + return new ChainableAssertions(ctx) +} + +export const attest: AttestFn = attestInternal as never diff --git a/ark/attest/assert/chainableAssertions.ts b/ark/attest/assert/chainableAssertions.ts new file mode 100644 index 0000000000..32eda196e6 --- /dev/null +++ b/ark/attest/assert/chainableAssertions.ts @@ -0,0 +1,312 @@ +import { caller } from "@arktype/fs" +import { + printable, + snapshot, + type Constructor, + type Guardable +} from "@arktype/util" +import * as assert from "node:assert/strict" +import { isDeepStrictEqual } from "node:util" +import { + getSnapshotByName, + queueSnapshotUpdate, + updateExternalSnapshot, + type SnapshotArgs +} from "../cache/snapshots.js" +import type { Completions } from "../cache/writeAssertionCache.js" +import { chainableNoOpProxy } from "../utils.js" +import { + TypeAssertionMapping, + assertEqualOrMatching, + assertEquals, + callAssertedFunction, + getThrownMessage, + throwAssertionError +} from "./assertions.js" +import type { AssertionContext } from "./attest.js" + +export type ChainableAssertionOptions = { + allowRegex?: boolean + defaultExpected?: unknown +} + +type AssertionRecord = Record, unknown> + +export class ChainableAssertions implements AssertionRecord { + constructor(private ctx: AssertionContext) {} + + private serialize(value: unknown) { + return snapshot(value) + } + + private get actual() { + return this.ctx.actual instanceof TypeAssertionMapping ? + this.ctx.actual.fn(this.ctx.typeAssertionEntries![0][1], this.ctx)! + .actual + : this.ctx.actual + } + + private get serializedActual() { + return this.serialize(this.actual) + } + + private snapRequiresUpdate(expectedSerialized: unknown) { + return ( + !isDeepStrictEqual(this.serializedActual, expectedSerialized) || + // If actual is undefined, we still need to write the "undefined" literal + // to the snap even though it will serialize to the same value as the (nonexistent) first arg + this.actual === undefined + ) + } + + narrow(predicate: Guardable, messageOnError?: string): never { + if (!predicate(this.actual)) { + throwAssertionError({ + ctx: this.ctx, + message: + messageOnError ?? + `${this.serializedActual} failed to satisfy predicate${predicate.name ? ` ${predicate.name}` : ""}` + }) + } + return this.actual as never + } + + get unknown(): this { + return this + } + + is(expected: unknown): this { + assert.equal(this.actual, expected) + return this + } + + equals(expected: unknown): this { + assertEquals(expected, this.actual, this.ctx) + return this + } + + instanceOf(expected: Constructor): this { + if (!(this.actual instanceof expected)) { + throwAssertionError({ + ctx: this.ctx, + message: `Expected an instance of ${expected.name} (was ${ + typeof this.actual === "object" && this.actual !== null ? + this.actual.constructor.name + : this.serializedActual + })` + }) + } + return this + } + + get snap(): snapProperty { + // Use variadic args to distinguish undefined being passed explicitly from no args + const inline = (...args: unknown[]) => { + const snapName = this.ctx.lastSnapName ?? "snap" + const expectedSerialized = this.serialize(args[0]) + if (!args.length || this.ctx.cfg.updateSnapshots) { + if (this.snapRequiresUpdate(expectedSerialized)) { + const snapshotArgs: SnapshotArgs = { + position: caller(), + serializedValue: this.serializedActual, + snapFunctionName: snapName + } + queueSnapshotUpdate(snapshotArgs) + } + } else { + // compare as strings, but if match fails, compare again as objects + // to give a clearer error message. This avoid problems with objects + // like subtypes of array that do not pass node's deep equality test + // but serialize to the same value. + if (printable(args[0]) !== printable(this.actual)) + assertEquals(expectedSerialized, this.serializedActual, this.ctx) + } + return this + } + const toFile = (id: string, opts?: ExternalSnapshotOptions) => { + const expectedSnapshot = getSnapshotByName( + this.ctx.position.file, + id, + opts?.path + ) + if (!expectedSnapshot || this.ctx.cfg.updateSnapshots) { + if (this.snapRequiresUpdate(expectedSnapshot)) { + updateExternalSnapshot({ + serializedValue: this.serializedActual, + position: caller(), + name: id, + customPath: opts?.path + }) + } + } else assertEquals(expectedSnapshot, this.serializedActual, this.ctx) + + return this + } + return Object.assign(inline, { toFile }) + } + + private immediateOrChained() { + const immediateAssertion = (...args: [expected: unknown]) => { + let expected + if (args.length) expected = args[0] + else { + if ("defaultExpected" in this.ctx) expected = this.ctx.defaultExpected + else { + throw new Error( + `Assertion call requires an arg representing the expected value.` + ) + } + } + if (this.ctx.allowRegex) + assertEqualOrMatching(expected, this.actual, this.ctx) + else assertEquals(expected, this.actual, this.ctx) + + return this + } + return new Proxy(immediateAssertion, { + get: (target, prop) => (this as any)[prop] + }) + } + + get throws(): unknown { + const result = callAssertedFunction(this.actual as Function) + this.ctx.actual = getThrownMessage(result, this.ctx) + this.ctx.allowRegex = true + this.ctx.defaultExpected = "" + return this.immediateOrChained() + } + + throwsAndHasTypeError(matchValue: string | RegExp): void { + assertEqualOrMatching( + matchValue, + getThrownMessage(callAssertedFunction(this.actual as Function), this.ctx), + this.ctx + ) + if (!this.ctx.cfg.skipTypes) { + assertEqualOrMatching( + matchValue, + new TypeAssertionMapping(data => ({ + actual: data.errors.join("\n") + })), + this.ctx + ) + } + } + + get completions(): any { + if (this.ctx.cfg.skipTypes) return chainableNoOpProxy + + this.ctx.actual = new TypeAssertionMapping(data => { + checkCompletionsForErrors(data.completions) + return { actual: data.completions } + }) + this.ctx.lastSnapName = "completions" + return this.snap + } + + get type(): any { + if (this.ctx.cfg.skipTypes) return chainableNoOpProxy + + // We need to bind this to return an object with getters + const self = this + return { + get toString() { + self.ctx.actual = new TypeAssertionMapping(data => ({ + actual: data.args[0].type + })) + return self.immediateOrChained() + }, + get errors() { + self.ctx.actual = new TypeAssertionMapping(data => ({ + actual: data.errors.join("\n") + })) + self.ctx.allowRegex = true + return self.immediateOrChained() + }, + get completions() { + return self.completions + } + } + } +} +const checkCompletionsForErrors = (completions?: Completions) => { + if (typeof completions === "string") throw new Error(completions) +} + +export type AssertionKind = "value" | "type" + +export type rootAssertions = valueAssertions< + t, + kind +> & + TypeAssertionsRoot + +export type valueAssertions< + t, + kind extends AssertionKind +> = comparableValueAssertion & + (t extends () => unknown ? functionAssertions : {}) + +export type nextAssertions = + "type" extends kind ? TypeAssertionsRoot : {} + +export type inferredAssertions< + argsType extends [value: any, ...rest: any[]], + kind extends AssertionKind, + chained = argsType[0] +> = rootAssertions & + ((...args: Args) => nextAssertions) + +export type ChainContext = { + allowRegex?: boolean + defaultExpected?: unknown +} + +export type functionAssertions = { + throws: inferredAssertions<[message: string | RegExp], kind, string> +} & ("type" extends kind ? + { + throwsAndHasTypeError: (message: string | RegExp) => undefined + } +: {}) + +export type valueFromTypeAssertion< + expected, + chained = expected +> = inferredAssertions<[expected: expected], "value", chained> + +type snapProperty = { + (expected?: snapshot): nextAssertions + toFile: ( + id: string, + options?: ExternalSnapshotOptions + ) => nextAssertions +} + +export type comparableValueAssertion = { + snap: snapProperty + equals: (value: expected) => nextAssertions + instanceOf: (constructor: Constructor) => nextAssertions + is: (value: expected) => nextAssertions + completions: (value?: Completions) => void + narrow( + predicate: (data: unknown) => data is narrowed, + messageOnError?: string + ): narrowed + // This can be used to assert values without type constraints + unknown: Omit, "unknown"> +} + +export type TypeAssertionsRoot = { + type: TypeAssertionProps +} + +export type TypeAssertionProps = { + toString: valueFromTypeAssertion + errors: valueFromTypeAssertion + completions: (value?: Completions) => void +} + +export type ExternalSnapshotOptions = { + path?: string +} diff --git a/ark/attest/bench/await1k.ts b/ark/attest/bench/await1k.ts new file mode 100644 index 0000000000..62346cf1c2 --- /dev/null +++ b/ark/attest/bench/await1k.ts @@ -0,0 +1,1002 @@ +export const await1K = async (fn: () => Promise): Promise => { + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() + await fn() +} diff --git a/ark/attest/bench/baseline.ts b/ark/attest/bench/baseline.ts new file mode 100644 index 0000000000..f2c4e04ea1 --- /dev/null +++ b/ark/attest/bench/baseline.ts @@ -0,0 +1,84 @@ +import { ensureDir } from "@arktype/fs" +import { snapshot } from "@arktype/util" +import { rmSync } from "node:fs" +import process from "node:process" +import { queueSnapshotUpdate } from "../cache/snapshots.js" +import { getConfig } from "../config.js" +import type { BenchAssertionContext, BenchContext } from "./bench.js" +import { + stringifyMeasure, + type MarkMeasure, + type Measure, + type MeasureComparison +} from "./measure.js" + +let isFirstQueuedUpdate = true + +export const queueBaselineUpdateIfNeeded = ( + updated: Measure | MarkMeasure, + baseline: Measure | MarkMeasure | undefined, + ctx: BenchAssertionContext +): void => { + // If we already have a baseline and the user didn't pass an update flag, do nothing + if (baseline && !ctx.cfg.updateSnapshots) return + + const serializedValue = snapshot(updated) + if (!ctx.lastSnapCallPosition) { + throw new Error( + `Unable to update baseline for ${ctx.qualifiedName} ('lastSnapCallPosition' was unset).` + ) + } + if (isFirstQueuedUpdate) { + // remove any leftover cached snaps before the first is written + const { benchSnapCacheDir } = getConfig() + rmSync(benchSnapCacheDir, { recursive: true, force: true }) + ensureDir(benchSnapCacheDir) + isFirstQueuedUpdate = false + } + queueSnapshotUpdate({ + position: ctx.lastSnapCallPosition, + serializedValue, + snapFunctionName: ctx.kind, + baselinePath: ctx.qualifiedPath + }) +} + +/** Pretty print comparison and set the process.exitCode to 1 if delta threshold is exceeded */ +export const compareToBaseline = ( + result: MeasureComparison, + ctx: BenchContext +): void => { + console.log(`🏌️ Result: ${stringifyMeasure(result.updated)}`) + if (result.baseline && !ctx.cfg.updateSnapshots) { + console.log(`⛳ Baseline: ${stringifyMeasure(result.baseline)}`) + const delta = + ((result.updated[0] - result.baseline[0]) / result.baseline[0]) * 100 + const formattedDelta = `${delta.toFixed(2)}%` + if (delta > ctx.cfg.benchPercentThreshold) + handlePositiveDelta(formattedDelta, ctx) + else if (delta < -ctx.cfg.benchPercentThreshold) + handleNegativeDelta(formattedDelta, ctx) + else console.log(`📊 Delta: ${delta > 0 ? "+" : ""}${formattedDelta}`) + } +} + +const handlePositiveDelta = (formattedDelta: string, ctx: BenchContext) => { + const message = `'${ctx.qualifiedName}' exceeded baseline by ${formattedDelta} (threshold is ${ctx.cfg.benchPercentThreshold}%).` + console.error(`📈 ${message}`) + if (ctx.cfg.benchErrorOnThresholdExceeded) { + process.exitCode = 1 + // Summarize failures at the end of output + process.on("exit", () => { + console.error(`❌ ${message}`) + }) + } +} + +const handleNegativeDelta = (formattedDelta: string, ctx: BenchContext) => { + console.log( + // Remove the leading negative when formatting our delta + `📉 ${ctx.qualifiedName} was under baseline by ${formattedDelta.slice( + 1 + )}! Consider setting a new baseline.` + ) +} diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts new file mode 100644 index 0000000000..4826c6dfcc --- /dev/null +++ b/ark/attest/bench/bench.ts @@ -0,0 +1,82 @@ +import { caller, type SourcePosition } from "@arktype/fs" +import { + ensureCacheDirs, + getConfig, + type ParsedAttestConfig +} from "../config.js" +import { chainableNoOpProxy } from "../utils.js" +import { BenchAssertions, type TimeAssertionName } from "./call.js" +import { createBenchTypeAssertion, type BenchTypeAssertions } from "./type.js" + +export type UntilOptions = { + ms?: number + count?: number +} + +export type BaseBenchOptions = { + until?: UntilOptions +} + +export type BenchOptions = BaseBenchOptions & { + hooks?: { + beforeCall?: () => void + afterCall?: () => void + } +} + +export type InternalBenchOptions = BenchOptions & { + fakeCallMs?: number | "count" +} + +export type BenchContext = { + qualifiedPath: string[] + qualifiedName: string + options: InternalBenchOptions + cfg: ParsedAttestConfig + benchCallPosition: SourcePosition + lastSnapCallPosition: SourcePosition | undefined + isAsync: boolean +} + +export type BenchAssertionContext = BenchContext & { + kind: TimeAssertionName | "types" +} + +export type BenchableFunction = () => unknown | Promise + +export type InitialBenchAssertions = + BenchAssertions & BenchTypeAssertions + +const currentSuitePath: string[] = [] + +export const bench = ( + name: string, + fn: Fn, + options: BenchOptions = {} +): InitialBenchAssertions => { + const qualifiedPath = [...currentSuitePath, name] + const ctx: BenchContext = { + qualifiedPath, + qualifiedName: qualifiedPath.join("/"), + options, + cfg: getConfig(), + benchCallPosition: caller(), + lastSnapCallPosition: undefined, + isAsync: fn.constructor.name === "AsyncFunction" + } + ensureCacheDirs() + if ( + typeof ctx.cfg.filter === "string" && + !qualifiedPath.includes(ctx.cfg.filter) + ) + return chainableNoOpProxy + else if ( + Array.isArray(ctx.cfg.filter) && + ctx.cfg.filter.some((segment, i) => segment !== qualifiedPath[i]) + ) + return chainableNoOpProxy + + const assertions = new BenchAssertions(fn, ctx) + Object.assign(assertions, createBenchTypeAssertion(ctx)) + return assertions as any +} diff --git a/ark/attest/bench/call.ts b/ark/attest/bench/call.ts new file mode 100644 index 0000000000..0d27c07e69 --- /dev/null +++ b/ark/attest/bench/call.ts @@ -0,0 +1,248 @@ +import { caller } from "@arktype/fs" +import { performance } from "node:perf_hooks" +import { chainableNoOpProxy } from "../utils.js" +import { await1K } from "./await1k.js" +import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" +import type { BenchContext, BenchableFunction, UntilOptions } from "./bench.js" +import { call1K } from "./call1k.js" +import { + createTimeComparison, + createTimeMeasure, + type MarkMeasure, + type Measure, + type TimeUnit +} from "./measure.js" +import { createBenchTypeAssertion, type BenchTypeAssertions } from "./type.js" + +export type StatName = keyof typeof stats + +export type TimeAssertionName = StatName | "mark" + +export const stats = { + mean: (callTimes: number[]): number => { + const totalCallMs = callTimes.reduce((sum, duration) => sum + duration, 0) + return totalCallMs / callTimes.length + }, + median: (callTimes: number[]): number => { + const middleIndex = Math.floor(callTimes.length / 2) + const ms = + callTimes.length % 2 === 0 ? + (callTimes[middleIndex - 1] + callTimes[middleIndex]) / 2 + : callTimes[middleIndex] + return ms + } +} + +class ResultCollector { + results: number[] = [] + private benchStart = performance.now() + private bounds: Required + private lastInvocationStart: number + + constructor(private ctx: BenchContext) { + // By default, will run for either 5 seconds or 100_000 call sets (of 1000 calls), whichever comes first + this.bounds = { + ms: 5000, + count: 100_000, + ...ctx.options.until + } + this.lastInvocationStart = -1 + } + + start() { + this.ctx.options.hooks?.beforeCall?.() + this.lastInvocationStart = performance.now() + } + + stop() { + this.results.push((performance.now() - this.lastInvocationStart) / 1000) + this.ctx.options.hooks?.afterCall?.() + } + + done() { + const metMsTarget = performance.now() - this.benchStart >= this.bounds.ms + const metCountTarget = this.results.length >= this.bounds.count + return metMsTarget || metCountTarget + } +} + +const loopCalls = (fn: () => void, ctx: BenchContext) => { + const collector = new ResultCollector(ctx) + while (!collector.done()) { + collector.start() + // we use a function like this to make 1k explicit calls to the function + // to avoid certain optimizations V8 makes when looping + call1K(fn) + collector.stop() + } + return collector.results +} + +const loopAsyncCalls = async (fn: () => Promise, ctx: BenchContext) => { + const collector = new ResultCollector(ctx) + while (!collector.done()) { + collector.start() + await await1K(fn) + collector.stop() + } + return collector.results +} + +export class BenchAssertions< + Fn extends BenchableFunction, + NextAssertions = BenchTypeAssertions, + ReturnedAssertions = Fn extends () => Promise ? Promise + : NextAssertions +> { + private label: string + private lastCallTimes: number[] | undefined + constructor( + private fn: Fn, + private ctx: BenchContext + ) { + this.label = `Call: ${ctx.qualifiedName}` + } + + private applyCallTimeHooks() { + if (this.ctx.options.fakeCallMs !== undefined) { + const fakeMs = + this.ctx.options.fakeCallMs === "count" ? + this.lastCallTimes!.length + : this.ctx.options.fakeCallMs + this.lastCallTimes = this.lastCallTimes!.map(() => fakeMs) + } + } + + private callTimesSync() { + if (!this.lastCallTimes) { + this.lastCallTimes = loopCalls(this.fn as any, this.ctx) + this.lastCallTimes.sort() + } + this.applyCallTimeHooks() + return this.lastCallTimes + } + + private async callTimesAsync() { + if (!this.lastCallTimes) { + this.lastCallTimes = await loopAsyncCalls(this.fn as any, this.ctx) + this.lastCallTimes.sort() + } + this.applyCallTimeHooks() + return this.lastCallTimes + } + + private createAssertion( + name: Name, + baseline: Name extends "mark" ? + Record> | undefined + : Measure | undefined, + callTimes: number[] + ) { + if (name === "mark") return this.markAssertion(baseline as any, callTimes) + + const ms: number = stats[name as StatName](callTimes) + const comparison = createTimeComparison(ms, baseline as Measure) + console.group(`${this.label} (${name}):`) + compareToBaseline(comparison, this.ctx) + console.groupEnd() + queueBaselineUpdateIfNeeded(createTimeMeasure(ms), baseline, { + ...this.ctx, + kind: name + }) + return this.getNextAssertions() + } + + private markAssertion( + baseline: MarkMeasure | undefined, + callTimes: number[] + ) { + console.group(`${this.label}:`) + const markEntries: [StatName, Measure | undefined][] = ( + baseline ? + Object.entries(baseline) + // If nothing was passed, gather all available baselines by setting their values to undefined. + : Object.entries(stats).map(([kind]) => [kind, undefined])) as any + const markResults = Object.fromEntries( + markEntries.map(([kind, kindBaseline]) => { + console.group(kind) + const ms = stats[kind](callTimes) + const comparison = createTimeComparison(ms, kindBaseline) + compareToBaseline(comparison, this.ctx) + console.groupEnd() + return [kind, comparison.updated] + }) + ) + console.groupEnd() + queueBaselineUpdateIfNeeded(markResults, baseline, { + ...this.ctx, + kind: "mark" + }) + return this.getNextAssertions() + } + + private getNextAssertions(): NextAssertions { + return createBenchTypeAssertion(this.ctx) as any as NextAssertions + } + + private createStatMethod( + name: Name, + baseline: Name extends "mark" ? + Record> | undefined + : Measure | undefined + ) { + if (this.ctx.isAsync) { + return new Promise(resolve => { + this.callTimesAsync().then( + callTimes => { + resolve(this.createAssertion(name, baseline, callTimes)) + }, + e => { + this.addUnhandledBenchException(e) + resolve(chainableNoOpProxy) + } + ) + }) + } + let assertions = chainableNoOpProxy + try { + assertions = this.createAssertion(name, baseline, this.callTimesSync()) + } catch (e) { + this.addUnhandledBenchException(e) + } + return assertions + } + + private addUnhandledBenchException(reason: unknown) { + const message = `Bench ${ + this.ctx.qualifiedName + } threw during execution:\n${String(reason)}` + console.error(message) + unhandledExceptionMessages.push(message) + } + + median(baseline?: Measure): ReturnedAssertions { + this.ctx.lastSnapCallPosition = caller() + return this.createStatMethod("median", baseline) + } + + mean(baseline?: Measure): ReturnedAssertions { + this.ctx.lastSnapCallPosition = caller() + return this.createStatMethod("mean", baseline) + } + + mark(baseline?: MarkMeasure): ReturnedAssertions { + this.ctx.lastSnapCallPosition = caller() + return this.createStatMethod("mark", baseline as any) + } +} + +const unhandledExceptionMessages: string[] = [] + +process.on("beforeExit", () => { + if (unhandledExceptionMessages.length) { + console.error( + `${unhandledExceptionMessages.length} unhandled exception(s) occurred during your benches (see details above).` + ) + process.exit(1) + } +}) diff --git a/ark/attest/bench/call1k.ts b/ark/attest/bench/call1k.ts new file mode 100644 index 0000000000..2d7ff41daa --- /dev/null +++ b/ark/attest/bench/call1k.ts @@ -0,0 +1,1002 @@ +export const call1K = (fn: () => void): void => { + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() + fn() +} diff --git a/ark/attest/bench/measure.ts b/ark/attest/bench/measure.ts new file mode 100644 index 0000000000..0fec3cc8d9 --- /dev/null +++ b/ark/attest/bench/measure.ts @@ -0,0 +1,92 @@ +import type { StatName } from "./call.js" + +type MeasureUnit = TimeUnit | TypeUnit + +export type Measure = [ + value: number, + unit: Unit +] + +export type MeasureComparison = { + updated: Measure + baseline: Measure | undefined +} + +export type MarkMeasure = Partial> + +export const stringifyMeasure = ([value, units]: Measure): string => + units in TIME_UNIT_RATIOS ? + stringifyTimeMeasure([value, units as TimeUnit]) + : `${value}${units}` + +export const TYPE_UNITS = ["instantiations"] as const + +export type TypeUnit = (typeof TYPE_UNITS)[number] + +export const createTypeComparison = ( + value: number, + baseline: Measure | undefined +): MeasureComparison => { + return { + updated: [value, "instantiations"], + baseline + } +} + +export const TIME_UNIT_RATIOS = Object.freeze({ + ns: 0.000_001, + us: 0.001, + ms: 1, + s: 1000 +}) + +export type TimeUnit = keyof typeof TIME_UNIT_RATIOS + +export const stringifyTimeMeasure = ([ + value, + unit +]: Measure): string => `${value.toFixed(2)}${unit}` + +const convertTimeUnit = (n: number, from: TimeUnit, to: TimeUnit) => { + return round((n * TIME_UNIT_RATIOS[from]) / TIME_UNIT_RATIOS[to], 2) +} + +/** + * Establish a new baseline using the most appropriate time unit + */ +export const createTimeMeasure = (ms: number): Measure => { + let bestMatch: Measure | undefined + for (const u in TIME_UNIT_RATIOS) { + const candidateMeasure = createTimeMeasureForUnit(ms, u as TimeUnit) + if (!bestMatch) bestMatch = candidateMeasure + else if (bestMatch[0] >= 1) { + if (candidateMeasure[0] >= 1 && candidateMeasure[0] < bestMatch[0]) + bestMatch = candidateMeasure + } else if (candidateMeasure[0] >= bestMatch[0]) bestMatch = candidateMeasure + } + return bestMatch! +} + +const createTimeMeasureForUnit = ( + ms: number, + unit: TimeUnit +): Measure => [convertTimeUnit(ms, "ms", unit), unit] + +const round = (value: number, decimalPlaces: number) => + Math.round(value * 10 ** decimalPlaces) / 10 ** decimalPlaces + +export const createTimeComparison = ( + ms: number, + baseline: Measure | undefined +): MeasureComparison => { + if (baseline) { + return { + updated: [convertTimeUnit(ms, "ms", baseline[1]), baseline[1]], + baseline + } + } + return { + updated: createTimeMeasure(ms), + baseline: undefined + } +} diff --git a/ark/attest/bench/type.ts b/ark/attest/bench/type.ts new file mode 100644 index 0000000000..45a127e793 --- /dev/null +++ b/ark/attest/bench/type.ts @@ -0,0 +1,158 @@ +import { caller, filePath } from "@arktype/fs" +import { throwInternalError } from "@arktype/util" +import * as tsvfs from "@typescript/vfs" +import ts from "typescript" +import { + TsServer, + getAbsolutePosition, + getAncestors, + getDescendants, + getInternalTypeChecker, + getTsConfigInfoOrThrow, + getTsLibFiles, + nearestCallExpressionChild +} from "../cache/ts.js" +import { getExpressionsByName } from "../cache/writeAssertionCache.js" +import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" +import type { BenchContext } from "./bench.js" +import { + createTypeComparison, + type Measure, + type MeasureComparison, + type TypeUnit +} from "./measure.js" + +export type BenchTypeAssertions = { + types: (instantiations?: Measure) => void +} + +const getIsolatedEnv = () => { + const tsconfigInfo = getTsConfigInfoOrThrow() + const libFiles = getTsLibFiles(tsconfigInfo.parsed.options) + const projectRoot = process.cwd() + const system = tsvfs.createFSBackedSystem( + libFiles.defaultMapFromNodeModules, + projectRoot, + ts + ) + return tsvfs.createVirtualTypeScriptEnvironment( + system, + [], + ts, + tsconfigInfo.parsed.options + ) +} + +const createFile = ( + env: tsvfs.VirtualTypeScriptEnvironment, + fileName: string, + fileText: string +) => { + env.createFile(fileName, fileText) + return env.getSourceFile(fileName) +} + +const getProgram = (env?: tsvfs.VirtualTypeScriptEnvironment) => { + return env?.languageService.getProgram() +} +const getInstantiationsWithFile = (fileText: string, fileName: string) => { + const env = getIsolatedEnv() + const file = createFile(env, fileName, fileText) + getProgram(env)?.emit(file) + const instantiationCount = getInternalTypeChecker(env).getInstantiationCount() + return instantiationCount +} + +const getFirstAncestorByKindOrThrow = (node: ts.Node, kind: ts.SyntaxKind) => + getAncestors(node).find(ancestor => ancestor.kind === kind) ?? + throwInternalError( + `Could not find an ancestor of kind ${ts.SyntaxKind[kind]}` + ) + +const getBaselineSourceFile = (originalFile: ts.SourceFile): string => { + const benchCalls = getExpressionsByName(originalFile, ["bench"]) + + const benchExpressions = benchCalls.map(node => + getFirstAncestorByKindOrThrow(node, ts.SyntaxKind.ExpressionStatement) + ) + + let baselineSourceFileText = originalFile.getFullText() + + benchExpressions.forEach(benchExpression => { + baselineSourceFileText = baselineSourceFileText.replace( + benchExpression.getFullText(), + "" + ) + }) + + return baselineSourceFileText +} + +const instantiationsByPath: { [path: string]: number } = {} + +const getInstantiationsContributedByNode = ( + benchBlock: ts.FunctionExpression | ts.ArrowFunction +) => { + const originalFile = benchBlock.getSourceFile() + const originalPath = filePath(originalFile.fileName) + const fakePath = originalPath + ".nonexistent.ts" + + const baselineFile = getBaselineSourceFile(originalFile) + + const baselineFileWithBenchBlock = + baselineFile + `\nconst $attestIsolatedBench = ${benchBlock.getFullText()}` + + if (!instantiationsByPath[fakePath]) { + console.log(`⏳ attest: Analyzing type assertions...`) + const instantiationsWithoutNode = getInstantiationsWithFile( + baselineFile, + fakePath + ) + + instantiationsByPath[fakePath] = instantiationsWithoutNode + console.log(`⏳ Cached type assertions \n`) + } + + const instantiationsWithNode = getInstantiationsWithFile( + baselineFileWithBenchBlock, + fakePath + ) + + return instantiationsWithNode - instantiationsByPath[fakePath] +} + +export const createBenchTypeAssertion = ( + ctx: BenchContext +): BenchTypeAssertions => ({ + types: (...args: [instantiations?: Measure | undefined]) => { + ctx.lastSnapCallPosition = caller() + const instance = TsServer.instance + const file = instance.getSourceFileOrThrow(ctx.benchCallPosition.file) + + const benchNode = nearestCallExpressionChild( + file, + getAbsolutePosition(file, ctx.benchCallPosition) + ) + const benchFn = getExpressionsByName(benchNode, ["bench"]) + if (!benchFn) throw new Error("Unable to retrieve bench expression node.") + + const benchBody = getDescendants(benchFn[0]).find( + node => ts.isArrowFunction(node) || ts.isFunctionExpression(node) + ) as ts.ArrowFunction | ts.FunctionExpression | undefined + + if (!benchBody) throw new Error("Unable to retrieve bench body node.") + + const instantiationsContributed = + getInstantiationsContributedByNode(benchBody) + + const comparison: MeasureComparison = createTypeComparison( + instantiationsContributed, + args[0] + ) + compareToBaseline(comparison, ctx) + queueBaselineUpdateIfNeeded(comparison.updated, args[0], { + ...ctx, + kind: "types" + }) + } +}) diff --git a/ark/attest/cache/getCachedAssertions.ts b/ark/attest/cache/getCachedAssertions.ts new file mode 100644 index 0000000000..d71d50bb8a --- /dev/null +++ b/ark/attest/cache/getCachedAssertions.ts @@ -0,0 +1,87 @@ +import { readJson, type LinePosition, type SourcePosition } from "@arktype/fs" +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { getConfig } from "../config.js" +import { getFileKey } from "../utils.js" +import type { + AssertionsByFile, + LinePositionRange, + TypeAssertionData +} from "./writeAssertionCache.js" + +export type VersionedAssertionsByFile = [ + tsVersion: string, + assertions: AssertionsByFile +] + +let assertionEntries: VersionedAssertionsByFile[] | undefined +export const getCachedAssertionEntries = (): VersionedAssertionsByFile[] => { + if (!assertionEntries) { + const config = getConfig() + if (!existsSync(config.assertionCacheDir)) + throwMissingAssertionDataError(config.assertionCacheDir) + + const assertionFiles = readdirSync(config.assertionCacheDir) + assertionEntries = assertionFiles.map(file => [ + // remove .json extension + file.slice(0, -5), + readJson(join(config.assertionCacheDir, file)) + ]) + } + return assertionEntries! +} + +const throwMissingAssertionDataError = (location: string) => { + throw new Error( + `Unable to find precached assertion data at '${location}'. ` + + `Ensure the 'setup' function from @arktype/attest has been called before running your tests.` + ) +} + +const isPositionWithinRange = ( + { line, char }: LinePosition, + { start, end }: LinePositionRange +) => { + if (line < start.line || line > end.line) return false + + if (line === start.line) return char >= start.char + + if (line === end.line) return char <= end.char + + return true +} + +export type VersionedTypeAssertion = [ + tsVersion: string, + assertionData: TypeAssertionData +] + +export const getTypeAssertionsAtPosition = ( + position: SourcePosition +): VersionedTypeAssertion[] => { + const fileKey = getFileKey(position.file) + return getCachedAssertionEntries().map(([version, data]) => { + if (!data[fileKey]) { + throw new Error( + `Found no assertion data for '${fileKey}' for TypeScript version ${version}.` + ) + } + const matchingAssertion = data[fileKey].find(assertion => { + /** + * Depending on the environment, a trace can refer to any of these points + * attest(...) + * ^ ^ ^ + * Because of this, it's safest to check if the call came from anywhere in the expected range. + * + */ + return 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}'. + Are sourcemaps enabled and working properly?` + ) + } + return [version, matchingAssertion] + }) +} diff --git a/ark/attest/cache/snapshots.ts b/ark/attest/cache/snapshots.ts new file mode 100644 index 0000000000..a3cb9d5373 --- /dev/null +++ b/ark/attest/cache/snapshots.ts @@ -0,0 +1,257 @@ +import { + filePath, + positionToString, + readFile, + readJson, + shell, + writeFile, + writeJson, + type SourcePosition +} from "@arktype/fs" +import { randomUUID } from "node:crypto" +import { existsSync, readdirSync, rmSync } from "node:fs" +import { basename, dirname, isAbsolute, join } from "node:path" +import type ts from "typescript" +import { getConfig } from "../config.js" +import { getFileKey } from "../utils.js" +import { + TsServer, + getAbsolutePosition, + nearestCallExpressionChild +} from "./ts.js" +import { getExpressionsByName } from "./writeAssertionCache.js" + +export type SnapshotArgs = { + position: SourcePosition + serializedValue: unknown + snapFunctionName?: string + baselinePath?: string[] +} + +export const resolveSnapshotPath = ( + testFile: string, + customPath: string | undefined +): string => { + if (customPath && isAbsolute(customPath)) return customPath + + return join(dirname(testFile), customPath ?? "assert.snapshots.json") +} + +export const getSnapshotByName = ( + file: string, + name: string, + customPath: string | undefined +): object => { + const snapshotPath = resolveSnapshotPath(file, customPath) + return readJson(snapshotPath)?.[basename(file)]?.[name] +} + +/** + * Writes the update and position to cacheDir, which will eventually be read and copied to the source + * file by a cleanup process after all tests have completed. + */ +export const queueSnapshotUpdate = (args: SnapshotArgs): void => { + const isBench = args.baselinePath + const config = getConfig() + writeJson( + join( + isBench ? config.benchSnapCacheDir : config.snapCacheDir, + `snap-${randomUUID()}.json` + ), + args + ) + if (isBench) writeSnapshotUpdatesOnExit() +} + +export type QueuedUpdate = { + position: SourcePosition + snapCall: ts.CallExpression + snapFunctionName: string + newArgText: string + baselinePath: string[] | undefined +} + +export type ExternalSnapshotArgs = SnapshotArgs & { + name: string + customPath: string | undefined +} + +const findCallExpressionAncestor = ( + position: SourcePosition, + functionName: string +): ts.CallExpression => { + const server = TsServer.instance + const file = server.getSourceFileOrThrow(position.file) + const absolutePosition = getAbsolutePosition(file, position) + const startNode = nearestCallExpressionChild(file, absolutePosition) + const calls = getExpressionsByName(startNode, [functionName], true) + if (calls.length) return startNode + + throw new Error( + `Unable to locate expected inline ${functionName} call from assertion at ${positionToString( + position + )}.` + ) +} + +export const updateExternalSnapshot = ({ + serializedValue: value, + position, + name, + customPath +}: ExternalSnapshotArgs): void => { + const snapshotPath = resolveSnapshotPath(position.file, customPath) + const snapshotData = readJson(snapshotPath) ?? {} + const fileKey = basename(position.file) + snapshotData[fileKey] = { + ...snapshotData[fileKey], + [name]: value + } + writeJson(snapshotPath, snapshotData) +} + +let snapshotsWillBeWritten = false +export const writeSnapshotUpdatesOnExit = (): void => { + if (snapshotsWillBeWritten) return + + process.on("exit", writeCachedInlineSnapshotUpdates) + snapshotsWillBeWritten = true +} + +/** + * This will fail if you have a sub process that writes cached snapshots and then deletes the snapshot cache that the root + * process is using + */ +const writeCachedInlineSnapshotUpdates = () => { + const config = getConfig() + const updates: QueuedUpdate[] = [] + if (existsSync(config.snapCacheDir)) + updates.push(...getQueuedUpdates(config.snapCacheDir)) + + if (existsSync(config.benchSnapCacheDir)) + updates.push(...getQueuedUpdates(config.benchSnapCacheDir)) + + writeUpdates(updates) + rmSync(config.snapCacheDir, { recursive: true, force: true }) + rmSync(config.benchSnapCacheDir, { recursive: true, force: true }) +} + +const getQueuedUpdates = (dir: string) => { + const queuedUpdates: QueuedUpdate[] = [] + for (const updateFile of readdirSync(dir)) { + if (/snap.*\.json$/.test(updateFile)) { + let snapshotData: SnapshotArgs | undefined + try { + snapshotData = readJson(join(dir, updateFile)) + } catch { + // If we can't read the snapshot, log an error and move onto the next update + console.error( + `Unable to read snapshot data from expected location ${updateFile}.` + ) + } + if (snapshotData) { + try { + queuedUpdates.push(snapshotArgsToQueuedUpdate(snapshotData)) + } catch (error) { + // If writeInlineSnapshotToFile throws an error, log it and move on to the next update + console.error(String(error)) + } + } + } + } + return queuedUpdates +} + +const snapshotArgsToQueuedUpdate = ({ + position, + serializedValue, + snapFunctionName = "snap", + baselinePath +}: SnapshotArgs): QueuedUpdate => { + const snapCall = findCallExpressionAncestor(position, snapFunctionName) + const newArgText = + typeof serializedValue === "string" && serializedValue.includes("\n") ? + "`" + serializedValue.replaceAll("`", "\\`") + "`" + : JSON.stringify(serializedValue) + return { + position, + snapCall, + snapFunctionName, + newArgText, + baselinePath + } +} + +// Waiting until process exit to write snapshots avoids invalidating existing source positions +export const writeUpdates = (queuedUpdates: QueuedUpdate[]): void => { + if (!queuedUpdates.length) return + + const updatesByFile: Record = {} + for (const update of queuedUpdates) { + updatesByFile[update.position.file] ??= [] + updatesByFile[update.position.file].push(update) + } + for (const k in updatesByFile) { + writeFileUpdates( + k, + updatesByFile[k].sort((l, r) => + l.position.line > r.position.line ? 1 + : r.position.line > l.position.line ? -1 + : l.position.char - r.position.char + ) + ) + } + runPrettierIfAvailable(queuedUpdates) +} + +const runPrettierIfAvailable = (queuedUpdates: QueuedUpdate[]) => { + try { + const updatedPaths = [ + ...new Set( + queuedUpdates.map(update => + filePath(update.snapCall.getSourceFile().fileName) + ) + ) + ] + shell(`npm exec --no -- prettier --write ${updatedPaths.join(" ")}`) + } catch { + // If prettier is unavailable, do nothing. + } +} + +const writeFileUpdates = (path: string, updates: QueuedUpdate[]) => { + let fileText = readFile(path) + let offSet = 0 + for (const update of updates) { + const previousArgTextLength = + update.snapCall.arguments.end - update.snapCall.arguments.pos + fileText = + fileText.slice(0, update.snapCall.arguments.pos + offSet) + + update.newArgText + + fileText.slice(update.snapCall.arguments.end + offSet) + offSet += update.newArgText.length - previousArgTextLength + summarizeSnapUpdate(update.snapCall.arguments, update) + } + writeFile(path, fileText) +} + +const summarizeSnapUpdate = ( + originalArgs: ts.NodeArray, + update: QueuedUpdate +) => { + let updateSummary = `${ + originalArgs.length ? "🆙 Updated" : "📸 Established" + } ` + updateSummary += + update.baselinePath ? + `baseline '${update.baselinePath.join("/")}' ` + : `snap at ${getFileKey(update.position.file)}:${update.position.line} ` + const previousValue = update.snapCall.arguments[0]?.getText() + updateSummary += + previousValue ? + `from ${previousValue} to ` + : `${update.baselinePath ? "at" : "as"} ` + + updateSummary += update.newArgText + console.log(updateSummary) +} diff --git a/ark/attest/cache/ts.ts b/ark/attest/cache/ts.ts new file mode 100644 index 0000000000..b5eb8bb07b --- /dev/null +++ b/ark/attest/cache/ts.ts @@ -0,0 +1,232 @@ +import { fromCwd, type SourcePosition } from "@arktype/fs" +import * as tsvfs from "@typescript/vfs" +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import ts from "typescript" +import { getConfig } from "../config.js" + +export class TsServer { + rootFiles!: string[] + virtualEnv!: tsvfs.VirtualTypeScriptEnvironment + + private static _instance: TsServer | null = null + static get instance(): TsServer { + return new TsServer() + } + + private constructor(private tsConfigInfo = getTsConfigInfoOrThrow()) { + if (TsServer._instance) return TsServer._instance + + const tsLibPaths = getTsLibFiles(tsConfigInfo.parsed.options) + + this.rootFiles = tsConfigInfo.parsed.fileNames.filter(path => + path.startsWith(fromCwd()) + ) + + const system = tsvfs.createFSBackedSystem( + tsLibPaths.defaultMapFromNodeModules, + dirname(this.tsConfigInfo.path), + ts + ) + + this.virtualEnv = tsvfs.createVirtualTypeScriptEnvironment( + system, + this.rootFiles, + ts, + this.tsConfigInfo.parsed.options + ) + + TsServer._instance = this + } + + getSourceFileOrThrow(path: string): ts.SourceFile { + const file = this.virtualEnv.getSourceFile(path) + if (!file) throw new Error(`Could not find ${path}.`) + + return file + } +} + +export const nearestCallExpressionChild = ( + node: ts.Node, + position: number +): ts.CallExpression => { + const result = nearestBoundingCallExpression(node, position) + if (!result) { + throw new Error( + `Unable to find bounding call expression at position ${position} in ${ + node.getSourceFile().fileName + }` + ) + } + return result +} + +const nearestBoundingCallExpression = ( + node: ts.Node, + position: number +): ts.CallExpression | undefined => + node.pos <= position && node.end >= position ? + node + .getChildren() + .flatMap( + child => nearestBoundingCallExpression(child, position) ?? [] + )[0] ?? (ts.isCallExpression(node) ? node : undefined) + : undefined + +export const getAbsolutePosition = ( + file: ts.SourceFile, + position: SourcePosition +): number => { + const pos = ts.getPositionOfLineAndCharacter( + file, + // TS uses 0-based line and char #s + position.line - 1, + position.char - 1 + ) + if (!pos) { + throw new Error( + `Absolute position was not able to be found in ${file.fileName}` + ) + } + return pos +} + +export type TsconfigInfo = { + path: string + parsed: ts.ParsedCommandLine +} + +export const getTsConfigInfoOrThrow = (): TsconfigInfo => { + const config = getConfig() + const configFilePath = + config.tsconfig ?? + ts.findConfigFile(fromCwd(), ts.sys.fileExists, "tsconfig.json") + if (!configFilePath) { + throw new Error( + `File ${config.tsconfig ?? join(fromCwd(), "tsconfig.json")} must exist.` + ) + } + + const configFileText = readFileSync(configFilePath).toString() + const result = ts.parseConfigFileTextToJson(configFilePath, configFileText) + if (result.error) { + throw new Error( + ts.formatDiagnostics([result.error], { + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: process.cwd, + getNewLine: () => ts.sys.newLine + }) + ) + } + + const configObject = result.config + const configParseResult = ts.parseJsonConfigFileContent( + configObject, + ts.sys, + dirname(configFilePath), + {}, + configFilePath + ) + + // ensure type.toString is as precise as possible + configParseResult.options.noErrorTruncation = true + + if (configParseResult.errors.length > 0) { + throw new Error( + ts.formatDiagnostics(configParseResult.errors, { + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: process.cwd, + getNewLine: () => ts.sys.newLine + }) + ) + } + + return { + path: configFilePath, + parsed: configParseResult + } +} + +export type TsLibFiles = { + defaultMapFromNodeModules: Map + resolvedPaths: string[] +} + +export const getTsLibFiles = ( + tsconfigOptions: ts.CompilerOptions +): TsLibFiles => { + const defaultMapFromNodeModules = + tsvfs.createDefaultMapFromNodeModules(tsconfigOptions) + const libPath = dirname(ts.getDefaultLibFilePath(tsconfigOptions)) + return { + defaultMapFromNodeModules, + resolvedPaths: [...defaultMapFromNodeModules.keys()].map(path => + join(libPath, path) + ) + } +} + +export const getProgram = ( + env?: tsvfs.VirtualTypeScriptEnvironment +): ts.Program => + env?.languageService.getProgram() ?? + TsServer.instance.virtualEnv.languageService.getProgram()! + +export interface InternalTypeChecker extends ts.TypeChecker { + // These APIs are not publicly exposed + getInstantiationCount: () => number + isTypeAssignableTo: (source: ts.Type, target: ts.Type) => boolean + getDiagnostics: () => ts.Diagnostic[] +} + +export const getInternalTypeChecker = ( + env?: tsvfs.VirtualTypeScriptEnvironment +): InternalTypeChecker => getProgram(env).getTypeChecker() as never + +export interface StringifiableType extends ts.Type { + toString(): string + isUnresolvable: boolean +} + +export const getStringifiableType = (node: ts.Node): StringifiableType => { + const typeChecker = getInternalTypeChecker() + // in a call like attest({a: true}), + // passing arg.expression avoids inferring {a: true} as object + const nodeType = typeChecker.getTypeAtLocation(node) + const stringified = typeChecker.typeToString(nodeType) + return Object.assign(nodeType, { + toString: () => stringified, + isUnresolvable: (nodeType as any).intrinsicName === "error" + }) +} + +export type ArgumentTypes = { + args: StringifiableType[] + typeArgs: StringifiableType[] +} + +export const extractArgumentTypesFromCall = ( + call: ts.CallExpression +): ArgumentTypes => ({ + args: call.arguments.map(arg => getStringifiableType(arg)), + typeArgs: + call.typeArguments?.map(typeArg => getStringifiableType(typeArg)) ?? [] +}) + +export const getDescendants = (node: ts.Node): ts.Node[] => + getDescendantsRecurse(node) + +const getDescendantsRecurse = (node: ts.Node): ts.Node[] => [ + node, + ...node.getChildren().flatMap(child => getDescendantsRecurse(child)) +] + +export const getAncestors = (node: ts.Node): ts.Node[] => { + const ancestors: ts.Node[] = [] + while (node.parent) { + ancestors.push(node) + node = node.parent + } + return ancestors +} diff --git a/ark/attest/cache/writeAssertionCache.ts b/ark/attest/cache/writeAssertionCache.ts new file mode 100644 index 0000000000..2897a107a1 --- /dev/null +++ b/ark/attest/cache/writeAssertionCache.ts @@ -0,0 +1,282 @@ +import type { LinePosition } from "@arktype/fs" +import { flatMorph } from "@arktype/util" +import ts from "typescript" +import { getConfig } from "../config.js" +import { getFileKey } from "../utils.js" +import { + TsServer, + extractArgumentTypesFromCall, + getDescendants, + getInternalTypeChecker, + type ArgumentTypes, + type StringifiableType +} from "./ts.js" + +export type AssertionsByFile = Record + +export const analyzeProjectAssertions = (): AssertionsByFile => { + const config = getConfig() + const instance = TsServer.instance + const filePaths = instance.rootFiles + const diagnosticsByFile = getDiagnosticsByFile() + const assertionsByFile: AssertionsByFile = {} + for (const path of filePaths) { + const file = instance.getSourceFileOrThrow(path) + const assertionsInFile = getAssertionsInFile( + file, + diagnosticsByFile, + config.attestAliases + ) + if (assertionsInFile.length) + assertionsByFile[getFileKey(file.fileName)] = assertionsInFile + } + return assertionsByFile +} + +export const getAssertionsInFile = ( + file: ts.SourceFile, + diagnosticsByFile: DiagnosticsByFile, + attestAliases: string[] +): TypeAssertionData[] => { + const assertCalls = getExpressionsByName(file, attestAliases) + return assertCalls.map(call => analyzeAssertCall(call, diagnosticsByFile)) +} + +export const getAssertCallLocation = ( + assertCall: ts.CallExpression +): LinePositionRange => { + const start = ts.getLineAndCharacterOfPosition( + assertCall.getSourceFile(), + assertCall.getStart() + ) + const end = ts.getLineAndCharacterOfPosition( + assertCall.getSourceFile(), + assertCall.getEnd() + ) + // Add 1 to everything, since trace positions are 1-based and TS positions are 0-based. + return { + start: { + line: start.line + 1, + char: start.character + 1 + }, + end: { + line: end.line + 1, + char: end.character + 1 + } + } +} + +export const getExpressionsByName = ( + startNode: ts.Node, + names: string[], + isSnapCall = false +): ts.CallExpression[] => { + /* + * We get might get some extraneous calls to other "attest" functions, + * but they won't be referenced at runtime so shouldn't matter. + */ + const calls: ts.CallExpression[] = [] + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + if (names.includes(node.expression.getText())) calls.push(node) + } else if (isSnapCall) { + if (ts.isIdentifier(node)) { + if (names.includes(node.getText())) + calls.push(node as any as ts.CallExpression) + } + } + ts.forEachChild(node, visit) + } + visit(startNode) + return calls +} + +export const analyzeAssertCall = ( + assertCall: ts.CallExpression, + diagnosticsByFile: DiagnosticsByFile +): TypeAssertionData => { + const types = extractArgumentTypesFromCall(assertCall) + const location = getAssertCallLocation(assertCall) + const args = types.args.map(arg => serializeArg(arg, types)) + const typeArgs = types.typeArgs.map(typeArg => serializeArg(typeArg, types)) + const errors = checkDiagnosticMessages(assertCall, diagnosticsByFile) + const completions = getCompletions(assertCall) + return { + location, + args, + typeArgs, + errors, + completions + } +} + +const serializeArg = ( + arg: StringifiableType, + context: ArgumentTypes +): ArgAssertionData => ({ + type: arg.toString(), + relationships: { + args: context.args.map(other => compareTsTypes(arg, other)), + typeArgs: context.typeArgs.map(other => compareTsTypes(arg, other)) + } +}) + +export type Completions = Record | string + +const getCompletions = (attestCall: ts.CallExpression) => { + const arg = attestCall.arguments[0] + if (arg === undefined) return {} + + const descendants = getDescendants(arg) + const file = attestCall.getSourceFile() + const text = file.getFullText() + const completions: Completions | string = {} + + for (const descendant of descendants) { + if (ts.isStringLiteral(descendant) || ts.isTemplateLiteral(descendant)) { + // descendant.pos tends to be an open quote while d.end tends to be right after the closing quote. + // It seems to be more consistent using this to get the pos for the completion over descendant.pos + const lastPositionOfInnerString = + descendant.end - (/["'`]/.test(text[descendant.end - 1]) ? 1 : 2) + const completionData = + TsServer.instance.virtualEnv.languageService.getCompletionsAtPosition( + file.fileName, + lastPositionOfInnerString, + undefined + ) + const prefix = + "text" in descendant ? descendant.text : descendant.getText() + + const entries = completionData?.entries ?? [] + + if (prefix in completions) + return `Encountered multiple completion candidates for string(s) '${prefix}'. Assertions on the same prefix must be split into multiple attest calls so the results can be distinguished.` + + completions[prefix] = [] + for (const entry of entries) { + if (entry.name.startsWith(prefix) && entry.name.length > prefix.length) + completions[prefix].push(entry.name) + } + } + } + + return flatMorph(completions, (prefix, entries) => + entries.length >= 1 ? [prefix, entries] : [] + ) +} + +export type DiagnosticData = { + start: number + end: number + message: string +} + +export type DiagnosticsByFile = Record + +export const getDiagnosticsByFile = (): DiagnosticsByFile => { + const diagnosticsByFile: DiagnosticsByFile = {} + const diagnostics: ts.Diagnostic[] = getInternalTypeChecker().getDiagnostics() + for (const diagnostic of diagnostics) + addDiagnosticDataFrom(diagnostic, diagnosticsByFile) + + return diagnosticsByFile +} + +const addDiagnosticDataFrom = ( + diagnostic: ts.Diagnostic, + diagnosticsByFile: DiagnosticsByFile +) => { + const filePath = diagnostic.file?.fileName + if (!filePath) return + + const fileKey = getFileKey(filePath) + const start = diagnostic.start ?? -1 + const end = start + (diagnostic.length ?? 0) + let message = diagnostic.messageText + if (typeof message === "object") message = concatenateChainedErrors([message]) + + const data: DiagnosticData = { + start, + end, + message + } + if (diagnosticsByFile[fileKey]) diagnosticsByFile[fileKey].push(data) + else diagnosticsByFile[fileKey] = [data] +} + +const concatenateChainedErrors = ( + diagnostics: ts.DiagnosticMessageChain[] +): string => + diagnostics + .map( + msg => + `${msg.messageText}${ + msg.next ? concatenateChainedErrors(msg.next) : "" + }` + ) + .join("\n") + +export type LinePositionRange = { + start: LinePosition + end: LinePosition +} + +export type ArgAssertionData = { + type: string + relationships: { + args: TypeRelationship[] + typeArgs: TypeRelationship[] + } +} +export type TypeAssertionData = { + location: LinePositionRange + args: ArgAssertionData[] + typeArgs: ArgAssertionData[] + errors: string[] + completions: Completions +} + +export type TypeRelationship = "subtype" | "supertype" | "equality" | "none" + +export const compareTsTypes = ( + l: StringifiableType, + r: StringifiableType +): TypeRelationship => { + const lString = l.toString() + const rString = r.toString() + // Ensure two unresolvable types are not treated as equivalent + if (l.isUnresolvable || r.isUnresolvable) return "none" + // Treat `any` as a supertype of every other type + if (lString === "any") return rString === "any" ? "equality" : "supertype" + if (rString === "any") return "subtype" + // Otherwise, determine if the types are equivalent by checking mutual assignability + const checker = getInternalTypeChecker() + const isSubtype = checker.isTypeAssignableTo(l, r) + const isSupertype = checker.isTypeAssignableTo(r, l) + return ( + isSubtype ? + isSupertype ? "equality" + : "subtype" + : isSupertype ? "supertype" + : "none" + ) +} + +export const checkDiagnosticMessages = ( + attestCall: ts.CallExpression, + diagnosticsByFile: DiagnosticsByFile +): string[] => { + const fileKey = getFileKey(attestCall.getSourceFile().fileName) + const fileDiagnostics = diagnosticsByFile[fileKey] + if (!fileDiagnostics) return [] + + const diagnosticMessagesInArgRange: string[] = [] + for (const diagnostic of fileDiagnostics) { + if ( + diagnostic.start >= attestCall.getStart() && + diagnostic.end <= attestCall.getEnd() + ) + diagnosticMessagesInArgRange.push(diagnostic.message) + } + return diagnosticMessagesInArgRange +} diff --git a/ark/attest/cli/cli.ts b/ark/attest/cli/cli.ts new file mode 100644 index 0000000000..7f08481408 --- /dev/null +++ b/ark/attest/cli/cli.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { fileName } from "@arktype/fs" +import { basename } from "path" +import { precache } from "./precache.js" +import { stats } from "./stats.js" +import { trace } from "./trace.js" + +const subcommands = { + precache, + trace, + stats +} + +type Subcommand = keyof typeof subcommands + +const baseFileName = basename(fileName()) + +const thisFileIndex = process.argv.findIndex( + // if running from build output in npm, will be a file called `attest` + // if running from build output in pnpm, will be cli.js in build output + s => s.endsWith(baseFileName) || s.endsWith("attest") +) + +if (thisFileIndex === -1) + throw new Error(`Expected to find an argument ending with "${baseFileName}"`) + +const subcommand = process.argv[thisFileIndex + 1] + +if (!(subcommand in subcommands)) { + console.error( + `Expected a command like 'attest ', where is one of:\n${Object.keys( + subcommands + )}` + ) + process.exit(1) +} + +const args = process.argv.slice(thisFileIndex + 2) + +subcommands[subcommand as Subcommand](args) diff --git a/ark/attest/cli/precache.ts b/ark/attest/cli/precache.ts new file mode 100755 index 0000000000..bc1b7e46b0 --- /dev/null +++ b/ark/attest/cli/precache.ts @@ -0,0 +1,10 @@ +import { ensureDir } from "@arktype/fs" +import { join } from "path" +import { writeAssertionData } from "../fixtures.js" + +export const precache = (args: string[]): void => { + const cacheFileToWrite = + args[0] ?? join(ensureDir(".attest"), "typescript.json") + + writeAssertionData(cacheFileToWrite) +} diff --git a/ark/attest/cli/shared.ts b/ark/attest/cli/shared.ts new file mode 100644 index 0000000000..e36d2d103b --- /dev/null +++ b/ark/attest/cli/shared.ts @@ -0,0 +1,2 @@ +export const baseDiagnosticTscCmd = + "pnpm tsc --noEmit --extendedDiagnostics --incremental false --tsBuildInfoFile null" diff --git a/ark/attest/cli/stats.ts b/ark/attest/cli/stats.ts new file mode 100644 index 0000000000..87c71be250 --- /dev/null +++ b/ark/attest/cli/stats.ts @@ -0,0 +1,71 @@ +import { execSync } from "child_process" +import { baseDiagnosticTscCmd } from "./shared.js" + +export const stats = (args: string[]): void => { + const packageDirs = args.length ? args : [process.cwd()] + const listedStats = packageDirs.map((packageDir): TypePerfStats => { + console.log(`⏳ Gathering type perf data for ${packageDir}...`) + let output: string + try { + output = execSync(baseDiagnosticTscCmd, { + cwd: packageDir, + stdio: "pipe" + }).toString() + } catch (e: any) { + output = e.stdout?.toString() ?? "" + output += e.stderr?.toString() ?? "" + console.error( + `❗Encountered one or more errors checking types for ${packageDir}- results may be inaccurate❗` + ) + } + const stats = parseTsDiagnosticsOutput(output) + logTypePerfStats(stats) + return stats + }) + + const aggregatedStats = listedStats.reduce( + (aggregatedStats, packageStats) => ({ + checkTime: aggregatedStats.checkTime + packageStats.checkTime, + types: aggregatedStats.types + packageStats.types, + instantiations: + aggregatedStats.instantiations + packageStats.instantiations + }), + { + checkTime: 0, + types: 0, + instantiations: 0 + } + ) + + console.log("📊 aggregated type performance:") + logTypePerfStats(aggregatedStats) +} + +type TypePerfStats = { + checkTime: number + types: number + instantiations: number +} + +const parseTsDiagnosticsOutput = (output: string): TypePerfStats => { + const lines = output.split("\n") + const results: TypePerfStats = { + checkTime: 0, + types: 0, + instantiations: 0 + } + + for (const line of lines) { + if (line.startsWith("Check time:")) + results.checkTime = parseFloat(line.split(":")[1].trim()) + else if (line.startsWith("Types:")) + results.types = parseInt(line.split(":")[1].trim(), 10) + else if (line.startsWith("Instantiations:")) + results.instantiations = parseInt(line.split(":")[1].trim(), 10) + } + return results +} + +const logTypePerfStats = (stats: TypePerfStats) => { + console.log(JSON.stringify(stats, null, 4)) +} diff --git a/ark/attest/cli/trace.ts b/ark/attest/cli/trace.ts new file mode 100644 index 0000000000..8956170fa5 --- /dev/null +++ b/ark/attest/cli/trace.ts @@ -0,0 +1,40 @@ +import { execSync } from "child_process" +import { existsSync } from "fs" +import { join } from "path" +import { baseDiagnosticTscCmd } from "./shared.js" + +export const trace = async (args: string[]): Promise => { + const packageDir = args[0] ?? process.cwd() + + if (!existsSync(packageDir)) { + throw new Error( + `trace requires an argument for an existing directory to trace, e.g. 'pnpm attest trace packages/api'` + ) + } + + try { + console.log(`⏳ Gathering type trace data for ${packageDir}...`) + // These cache files have to be removed before any analysis is done otherwise + // the results will be meaningless. + // the .tstrace directory will contain a trace.json file and a types.json file. + // the trace.json file can be viewed via a tool like https://ui.perfetto.dev/ + // the types.json file can be used to associate IDs from the trace file with type aliases + execSync(`${baseDiagnosticTscCmd} --generateTrace .tstrace`, { + cwd: packageDir, + stdio: "inherit" + }) + } catch (e) { + console.error(String(e)) + } finally { + console.log(`⏳ Analyzing type trace data for ${packageDir}...`) + // allow analyze-trace to process the args + process.argv = [ + "node", + "node_modules/@typescript/analyze-trace/dist/analyze-trace-dir.js", + join(packageDir, ".tstrace") + ] + // TypeScript's analyze-trace tool can be used to automatically detect hot-spots in your code. + // It's not a perfect match for what can be optimized, but it can be a helpful place to start + await import("@typescript/analyze-trace/dist/analyze-trace-dir.js") + } +} diff --git a/ark/attest/config.ts b/ark/attest/config.ts new file mode 100644 index 0000000000..e29044606e --- /dev/null +++ b/ark/attest/config.ts @@ -0,0 +1,159 @@ +import { ensureDir, fromCwd } from "@arktype/fs" +import { + arrayFrom, + isArray, + tryParseNumber, + type autocomplete +} from "@arktype/util" +import { existsSync } from "node:fs" +import { join, resolve } from "node:path" +import { + findAttestTypeScriptVersions, + type TsVersionData +} from "./tsVersioning.js" + +export type TsVersionAliases = autocomplete<"*"> | string[] + +type BaseAttestConfig = { + tsconfig: string | undefined + updateSnapshots: boolean + /** A string or list of strings representing the TypeScript version aliases to run. + * + * Aliases must be specified as a package.json dependency or devDependency beginning with "typescript". + * Alternate aliases can be specified using the "npm:" prefix: + * ```json + * "typescript": "latest", + * "typescript-next: "npm:typescript@next", + * "typescript-1": "npm:typescript@5.2" + * "typescript-2": "npm:typescript@5.1" + * ``` + * + * "*" can be pased to run all discovered versions beginning with "typescript". + */ + tsVersions: TsVersionAliases | TsVersionData[] + skipTypes: boolean + attestAliases: string[] + benchPercentThreshold: number + benchErrorOnThresholdExceeded: boolean + filter: string | undefined +} + +export type AttestConfig = Partial + +export const getDefaultAttestConfig = (): BaseAttestConfig => { + return { + tsconfig: + existsSync(fromCwd("tsconfig.json")) ? + fromCwd("tsconfig.json") + : undefined, + attestAliases: ["attest", "attestInternal"], + updateSnapshots: false, + skipTypes: false, + tsVersions: "typescript", + benchPercentThreshold: 20, + benchErrorOnThresholdExceeded: false, + filter: undefined + } +} + +const hasFlag = (flag: keyof AttestConfig) => + process.argv.some(arg => arg.includes(flag)) + +const getParamValue = (param: keyof AttestConfig) => { + const paramIndex = process.argv.findIndex(arg => arg.includes(param)) + if (paramIndex === -1) return undefined + + const raw = process.argv[paramIndex + 1] + if (raw === "true") return true + + if (raw === "false") return false + + if (param === "benchPercentThreshold") + return tryParseNumber(raw, { errorOnFail: true }) + + if (param === "tsVersions" || param === "attestAliases") return raw.split(",") + + return raw +} + +export const attestEnvPrefix = "ATTEST_" + +const addEnvConfig = (config: BaseAttestConfig) => { + Object.entries(process.env as Record).forEach(([k, v]) => { + if (k.startsWith(attestEnvPrefix)) { + const optionName = k.slice(attestEnvPrefix.length) + if (optionName === "CONFIG") Object.assign(config, JSON.parse(v)) + else (config as any)[optionName] = JSON.parse(v) + } + }) + let k: keyof BaseAttestConfig + for (k in config) { + if (config[k] === false) config[k] = hasFlag(k) as never + else { + const value = getParamValue(k) + if (value !== undefined) config[k] = value as never + } + } + return config +} + +export interface ParsedAttestConfig extends Readonly { + cacheDir: string + snapCacheDir: string + benchSnapCacheDir: string + assertionCacheDir: string + tsVersions: TsVersionData[] +} + +const parseConfig = (): ParsedAttestConfig => { + const baseConfig = addEnvConfig(getDefaultAttestConfig()) + const cacheDir = resolve(".attest") + const snapCacheDir = join(cacheDir, "snaps") + const benchSnapCacheDir = join(cacheDir, "benchSnaps") + const assertionCacheDir = join(cacheDir, "assertions") + + return Object.assign(baseConfig, { + cacheDir, + snapCacheDir, + benchSnapCacheDir, + assertionCacheDir, + tsVersions: + baseConfig.skipTypes ? [] + : isTsVersionAliases(baseConfig.tsVersions) ? + parseTsVersions(baseConfig.tsVersions) + : baseConfig.tsVersions + }) +} + +const isTsVersionAliases = ( + v: AttestConfig["tsVersions"] +): v is TsVersionAliases => + typeof v === "string" || (isArray(v) && typeof v[0] === "string") + +const parseTsVersions = (aliases: TsVersionAliases): TsVersionData[] => { + const versions = findAttestTypeScriptVersions() + if (aliases === "*") return versions + + return arrayFrom(aliases).map(alias => { + const matching = versions.find(v => v.alias === alias) + if (!matching) { + throw new Error( + `Specified TypeScript version ${alias} does not exist.` + + ` It should probably be specified in package.json like: +"typescript-${alias}": "npm:typescript@latest"` + ) + } + return matching + }) +} + +const cachedConfig = parseConfig() + +export const getConfig = (): ParsedAttestConfig => cachedConfig + +export const ensureCacheDirs = (): void => { + ensureDir(cachedConfig.cacheDir) + ensureDir(cachedConfig.snapCacheDir) + ensureDir(cachedConfig.benchSnapCacheDir) + ensureDir(cachedConfig.assertionCacheDir) +} diff --git a/ark/attest/fixtures.ts b/ark/attest/fixtures.ts new file mode 100644 index 0000000000..832c17ede2 --- /dev/null +++ b/ark/attest/fixtures.ts @@ -0,0 +1,43 @@ +import { shell, writeJson } from "@arktype/fs" +import { rmSync } from "node:fs" +import { join } from "node:path" +import { writeSnapshotUpdatesOnExit } from "./cache/snapshots.js" +import { analyzeProjectAssertions } from "./cache/writeAssertionCache.js" +import { ensureCacheDirs, getConfig, type AttestConfig } from "./config.js" +import { forTypeScriptVersions } from "./tsVersioning.js" + +export const setup = (options: Partial = {}): void => { + const config = getConfig() + Object.assign(config, options) + rmSync(config.cacheDir, { recursive: true, force: true }) + ensureCacheDirs() + if (config.skipTypes) return + + if ( + config.tsVersions.length === 1 && + config.tsVersions[0].alias === "typescript" + ) + writeAssertionData(join(config.assertionCacheDir, "typescript.json")) + else { + forTypeScriptVersions(config.tsVersions, version => + shell( + `npm exec -c "attestPrecache ${join( + config.assertionCacheDir, + version.alias + ".json" + )}"` + ) + ) + } +} + +export const writeAssertionData = (toPath: string): void => { + console.log( + "⏳ Waiting for TypeScript to check your project (this may take a while)..." + ) + writeJson(toPath, analyzeProjectAssertions()) +} + +export const cleanup = (): void => writeSnapshotUpdatesOnExit() + +/** alias for cleanup to align with vitest and others */ +export const teardown = cleanup diff --git a/ark/attest/main.ts b/ark/attest/main.ts new file mode 100644 index 0000000000..1f23315ca7 --- /dev/null +++ b/ark/attest/main.ts @@ -0,0 +1,17 @@ +export { caller, type CallerOfOptions } from "@arktype/fs" +export { attest } from "./assert/attest.js" +export { bench } from "./bench/bench.js" +export { getTypeAssertionsAtPosition } from "./cache/getCachedAssertions.js" +export type { + ArgAssertionData, + LinePositionRange, + TypeAssertionData, + TypeRelationship +} from "./cache/writeAssertionCache.js" +export { getDefaultAttestConfig, type AttestConfig } from "./config.js" +export { cleanup, setup, teardown, writeAssertionData } from "./fixtures.js" +export { + findAttestTypeScriptVersions, + getPrimaryTsVersionUnderTest +} from "./tsVersioning.js" +export { contextualize } from "./utils.js" diff --git a/ark/attest/package.json b/ark/attest/package.json new file mode 100644 index 0000000000..4b29c1e8ea --- /dev/null +++ b/ark/attest/package.json @@ -0,0 +1,47 @@ +{ + "name": "@arktype/attest", + "version": "0.7.0", + "author": { + "name": "David Blass", + "email": "david@arktype.io", + "url": "https://arktype.io" + }, + "type": "module", + "main": "./out/main.js", + "types": "./out/main.d.ts", + "exports": { + ".": { + "types": "./out/main.d.ts", + "default": "./out/main.js" + }, + "./internal/*": { + "default": "./out/*" + } + }, + "files": [ + "out", + "!__tests__", + "**/*.ts" + ], + "bin": { + "attest": "./out/cli/cli.js" + }, + "scripts": { + "build": "tsx ../repo/build.ts", + "test": "tsx ../repo/testPackage.ts", + "bunTest": "bun test --preload ../repo/bunTestSetup.ts" + }, + "dependencies": { + "arktype": "workspace:*", + "@arktype/fs": "workspace:*", + "@arktype/util": "workspace:*", + "@typescript/vfs": "1.5.0", + "@typescript/analyze-trace": "0.10.1" + }, + "devDependencies": { + "typescript": "5.5.0-beta" + }, + "peerDependencies": { + "typescript": "*" + } +} diff --git a/ark/attest/tsVersioning.ts b/ark/attest/tsVersioning.ts new file mode 100644 index 0000000000..4088498bbe --- /dev/null +++ b/ark/attest/tsVersioning.ts @@ -0,0 +1,144 @@ +import { findPackageRoot, fsRoot, readJson } from "@arktype/fs" +import type { Digit } from "@arktype/util" +import { existsSync, renameSync, symlinkSync, unlinkSync } from "fs" +import { dirname } from "path" +import { join } from "path/posix" +import ts from "typescript" + +/** + * Executes a provided function for an installed set of TypeScript versions. + * + * Your primary TypeScript version at node_modules/typescript will be + * temporarily renamed to node_modules/typescript-temp, and reset after each + * version has been executed, regardless of failures. + * + * + * Throws an error if any version fails when the associated function is executed. + * + * @param {TsVersionData[]} versions The set of versions for which to exceute the function + * @param {function} fn - The function to execute for each TypeScript version. + * Should spawn a new process so the new symlinked version can be loaded. + */ +export const forTypeScriptVersions = ( + versions: TsVersionData[], + fn: (version: TsVersionData) => void +): void => { + const passedVersions: TsVersionData[] = [] + const failedVersions: TsVersionData[] = [] + const nodeModules = join(findPackageRoot(process.cwd()), "node_modules") + const tsPrimaryPath = join(nodeModules, "typescript") + const tsTemporaryPath = join(nodeModules, "typescript-temp") + if (existsSync(tsPrimaryPath)) renameSync(tsPrimaryPath, tsTemporaryPath) + + try { + for (const version of versions) { + const targetPath = + version.path === tsPrimaryPath ? tsTemporaryPath : version.path + const tsPackageJson = readJson(join(targetPath, "package.json")) + if (tsPackageJson.name !== "typescript") { + throw new Error( + `Expected to find a TypeScript version ${version.version} at ${version.path}` + ) + } + console.log( + `⛵ Switching to TypeScript version ${version.alias} (${version.version})...` + ) + + try { + if (existsSync(tsPrimaryPath)) unlinkSync(tsPrimaryPath) + + symlinkSync(targetPath, tsPrimaryPath) + fn(version) + passedVersions.push(version) + } catch (e) { + console.error(e) + failedVersions.push(version) + } + } + + if (failedVersions.length !== 0) { + throw new Error( + `❌ The following TypeScript versions threw: ${failedVersions + .map(v => `${v.alias} (${v.version})`) + .join(", ")}` + ) + } + console.log( + `✅ Successfully ran TypeScript versions ${passedVersions + .map(v => `${v.alias} (${v.version})`) + .join(", ")}` + ) + } finally { + if (existsSync(tsTemporaryPath)) { + console.log(`⏮️ Restoring your original TypeScript version...`) + renameSync(tsTemporaryPath, tsPrimaryPath) + } + } +} + +export type TsVersionData = { + alias: string + version: string + path: string +} + +/** + * Find and return the paths of all installed TypeScript versions, including the + * primary version installed as "typescript" and all dependencies beginning with + * "typescript-". + * + * Starts checking from the current directory and looks for node_modules in parent + * directories up to the file system root. + * + * Alternate versions can be installed using a package.json dependency like: + * + * ```json + * "typescript-latest": "npm:typescript@latest" + * ``` + * @returns {TsVersionData[]} Each version mapped to data, e.g.: + * {alias: "typescript-latest", version: "5.3.3", path: "/home/ssalb/arktype/node_modules/typescript-latest" } + * + * @throws {Error} If a TypeScript version specified in package.json is not + * installed at the expected location in node_modules. + */ +export const findAttestTypeScriptVersions = (): TsVersionData[] => { + let currentDir = process.cwd() + const versions: TsVersionData[] = [] + while (currentDir !== fsRoot) { + const packageJsonPath = join(currentDir, "package.json") + if (!existsSync(packageJsonPath)) { + currentDir = dirname(currentDir) + continue + } + const nodeModulesPath = join(currentDir, "node_modules") + const packageJson = readJson(packageJsonPath) + const dependencies: Record = { + ...packageJson.dependencies, + ...packageJson.devDependencies + } + for (const alias in dependencies) { + if (!alias.startsWith("typescript")) continue + + const path = join(nodeModulesPath, alias) + if (!existsSync(path)) { + throw Error( + `TypeScript version ${alias} specified in ${packageJsonPath} must be installed at ${path} ` + ) + } + const version: string = readJson(join(path, "package.json")).version + versions.push({ + alias, + version, + path + }) + } + currentDir = dirname(currentDir) + } + return versions +} + +/** Get the TypeScript version being used by attest as as string like "5.0" + * Does not include alternate versions that may be referenced by cache files + */ +export const getPrimaryTsVersionUnderTest = (): `${Digit}.${Digit}` => + ts.versionMajorMinor diff --git a/ark/attest/tsconfig.build.json b/ark/attest/tsconfig.build.json new file mode 120000 index 0000000000..80a796b963 --- /dev/null +++ b/ark/attest/tsconfig.build.json @@ -0,0 +1 @@ +../repo/tsconfig.build.json \ No newline at end of file diff --git a/ark/attest/utils.ts b/ark/attest/utils.ts new file mode 100644 index 0000000000..0267084702 --- /dev/null +++ b/ark/attest/utils.ts @@ -0,0 +1,74 @@ +import { caller } from "@arktype/fs" +import { basename, relative } from "node:path" + +export const getFileKey = (path: string): string => relative(".", path) + +/** + * Can be used to allow arbitrarily chained property access and function calls. + */ +export const chainableNoOpProxy: any = new Proxy(() => chainableNoOpProxy, { + get: () => chainableNoOpProxy +}) + +export type ContextualizeBlock = { + (tests: () => void): void + (nameA: string, testsA: () => void): void + (nameA: string, testsA: () => void, nameB: string, testsB: () => void): void + ( + nameA: string, + testsA: () => void, + nameB: string, + testsB: () => void, + nameC: string, + testsC: () => void + ): void + ( + nameA: string, + testsA: () => void, + nameB: string, + testsB: () => void, + nameC: string, + testsC: () => void, + nameD: string, + testsD: () => void + ): void + ( + nameA: string, + testsA: () => void, + nameB: string, + testsB: () => void, + nameC: string, + testsC: () => void, + nameD: string, + testsD: () => void, + nameE: string, + testsE: () => void + ): void + ( + nameA: string, + testsA: () => void, + nameB: string, + testsB: () => void, + nameC: string, + testsC: () => void, + nameD: string, + testsD: () => void, + nameE: string, + testsE: () => void, + nameF: string, + testsF: () => void + ): void +} + +export const contextualize: ContextualizeBlock = (...args: any[]) => { + if (globalThis.describe as unknown) { + const fileName = basename(caller().file) + if (typeof args[0] === "function") globalThis.describe(fileName, args[0]) + else { + globalThis.describe(fileName, () => { + for (let i = 0; i < args.length; i = i + 2) + globalThis.describe(args[i], args[i + 1]) + }) + } + } +} diff --git a/ark/dark/LICENSE b/ark/dark/LICENSE new file mode 120000 index 0000000000..30cff7403d --- /dev/null +++ b/ark/dark/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/ark/dark/README.md b/ark/dark/README.md new file mode 100644 index 0000000000..f216b5fac9 --- /dev/null +++ b/ark/dark/README.md @@ -0,0 +1,41 @@ +
+ +

ArkDark

+
+
+ +[ArkType](https://arktype.io) syntax highlighting and theme⛵ + +
+ +We're building a 1:1 validator for TypeScript! Check out our core project [on GitHub](https://github.com/arktypeio/arktype)! + +## Syntax Highlighting + +This extension provides syntax highlighting for strings that are part of an ArkType definition: + +![syntax highlighting](./highlighting.png) + +## ArkDark Theme + +It also includes an editor theme based on ArkType and optimized for type syntax: + +![theme](./theme.png) + +## Extending This Theme + +- **pnpm build** to generate the arkdark.json theme +- **F5** or **Run > Debugger**, will launch the extension in another window, allowing you to see the changes on any repo you open up + +Looking to edit the theme? **(Changes are immediately reflected)** + +- **themes** > **arkdark.json** + +Looking to change the textmate scopes? **(Must restart the debugger to view changes)** + +- arktype.tmLanguage.json + +Current textmate scopes can be viewed: + +- Open: Command Palette **(Ctrl + Shift + P)** +- Search: **Developer: Inspect Editor Tokens and Scopes** diff --git a/ark/dark/arktype.scratch.ts b/ark/dark/arktype.scratch.ts new file mode 100644 index 0000000000..232902829f --- /dev/null +++ b/ark/dark/arktype.scratch.ts @@ -0,0 +1,129 @@ +// @ts-nocheck +import { scope, type } from "arktype" + +// TODO: remove alpha helpers from syntax highlighting +type("(boolean | number | 'foo')[]") + +const creditCard = type( + "/^(?:4[0-9]{12}(?:[0-9]{3,6})?|5[1-5][0-9]{14}|(222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}|6(?:011|5[0-9][0-9])[0-9]{12,15}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35d{3})d{11}|6[27][0-9]{14}|^(81[0-9]{14,17}))$/" +) + +enum Foo { + Bar +} + +// Should be highlighted +Foo.BAR + +type({ + a: "string|number[]" +}) + +export const tsGenerics = Scope.root({ + "Record": node({ domain: "object" }) +}) + +type(["string|numer", "[]"]) + +const a = "string" +const b = "boolean" +const c = "number" + +const t = type(a).and(b).and(c) +const z = { + a: true +} + +const factor = (s: string) => s + +// not highlighted +factor("foo|bar") +// not highglighted +or("foo|bar") + +const ff = type("string").or("foobar|baz") + +const types = scope({ notASpace: { a: type("string") } }).export() +attest>(types.notASpace) + +test("type definition", () => { + const types = scope({ a: type("string") }).export() + attest(types.a.infer) + attest(() => + // @ts-expect-error + scope({ a: type("strong") }) + ).throwsAndHasTypeError(writeUnresolvableMessage("strong")) +}) + +const $ = scope({ + b: "3.14", + a: () => $.type("number").pipe(data => `${data}`), + aAndB: () => $.type("a&b"), + bAndA: () => $.type("b&a") +}) + +scope({ + // nested highlighting + a: "string|number", + b: [ + { + nested: "a" + } + ] +}) + +{ + const type = (arg?: any) => {} + type({ + foo: "string|number" + }) + const obj = { + type + } + obj.type({}) + // syntax should still be correctly highlighted + const foo = {} + + const outer = (...args: any[]) => obj + + outer("ark", () => { + const arkType = type({ + number: "number", + negNumber: "number", + maxNumber: "number", + string: "string", + longString: "string", + boolean: "boolean", + deeplyNested: { + foo: "string", + num: "number", + bool: "boolean" + } + }) + }).type() + const t = type(`${2} f + const abc = func($.type("string")) +} + +class F { + static compile(rule: PropRule[]) { + const named = rule.filter(isNamed) + if (named.length === rule.length) { + return this.compileNamed(named) + } + const indexed = rule.filter(isIndexed) + return condition + } +} + +// This is used to generate highlighting.png +const highlighted = type({ + literals: "'foo' | 'bar' | true", + expressions: "boolean[] | 5 < number <= 10 | number % 2", + regex: "/^(?:4[0-9]{12}(?:[0-9]{3,6}))$/" +}) diff --git a/ark/dark/color-theme.json b/ark/dark/color-theme.json new file mode 100644 index 0000000000..7358a6c8fc --- /dev/null +++ b/ark/dark/color-theme.json @@ -0,0 +1,257 @@ +{ + "$schema": "vscode://schemas/color-theme", + "name": "ArkDark", + "tokenColors": [ + { + "name": "functions", + "scope": [ + "entity.name.function", + "meta.function-call", + "support.function", + // 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" + } + }, + { + "name": "types", + "scope": ["entity.name.type"], + "settings": { + "foreground": "#40decc" + } + }, + { + "name": "keywords and operators", + "scope": [ + "keyword", + "storage", + "punctuation", + "constant.character.escape" + ], + "settings": { + "foreground": "#eb9f2e" + } + }, + { + "name": "italics", + // italicize keywords that are not punctuation/symbols + "scope": ["keyword", "storage", "keyword.operator.expression"], + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "unitalicized", + "scope": ["keyword.operator", "storage.type.function.arrow.ts"], + "settings": { + "fontStyle": "" + } + }, + { + "name": "properties and strings", + "scope": [ + "variable.other.property", + "variable.other.constant.property", + "string" + ], + "settings": { + "foreground": "#f5cf8f" + } + }, + { + "name": "this, primitives, built-in types, quotes", + "scope": [ + // this + "variable.language", + "constant", + // also includes JSON keys + "support.type", + // YAML keys + "entity.name.tag", + "punctuation.definition.string.begin", + "punctuation.definition.string.end", + "punctuation.definition.string.template.begin", + "punctuation.definition.string.template.end" + ], + "settings": { + "foreground": "#408fde" + } + }, + { + "name": "errors", + "scope": ["invalid", "support.type.exception"], + "settings": { + "foreground": "#9558f8" + } + }, + { + "name": "comment", + "scope": ["comment", "punctuation.definition.comment"], + "settings": { + "foreground": "#888899" + } + }, + { + // re-specifying the foreground color explicitly for variable avoids + // some edge cases like variables in template strings being + // highlighted as strings + "name": "variable", + "scope": ["variable", "meta.function-call.arguments"], + "settings": { + "foreground": "#eeeeee" + } + } + ], + "colors": { + // Customized built-in settings + "editor.foreground": "#eeeeee", + "errorForeground": "#9558f8", + "editorError.foreground": "#9558f8", + "editorWarning.foreground": "#2faf64", + "minimap.errorHighlight": "#9558f8", + "list.errorForeground": "#9558f8", + "editorOverviewRuler.errorForeground": "#9558f8", + "editorBracketHighlight.foreground1": "#f5cf8f", + "editorBracketHighlight.foreground2": "#eb9f2e", + "editorBracketHighlight.foreground3": "#f5cf8f", + "editorBracketHighlight.unexpectedBracket.foreground": "#eb9f2e", + "editorCursor.foreground": "#408fde", + "terminalCursor.foreground": "#408fde", + "editorCodeLens.foreground": "#00ccff60", + "editorInlayHint.foreground": "#00ccff60", + // ErrorLens settings + "errorLens.errorBackground": "#9558f818", + "errorLens.errorForeground": "#9558f8a0", + "errorLens.warningBackground": "#00b7e40c", + "errorLens.warningForeground": "#00ccff60", + "errorLens.infoBackground": "#00b7e40c", + "errorLens.infoForeground": "#00ccff60", + "errorLens.hintBackground": "#17a2a20c", + "errorLens.hintForeground": "#2faf6460", + // Copied from VSCode's "Default Dark Modern" (https://github.com/microsoft/vscode/blob/main/extensions/theme-defaults/themes/dark_modern.json) + // Overridden defaults should be commented out here + // "editor.foreground": "#CCCCCC", + // "errorForeground": "#F85149", + "activityBar.activeBorder": "#0078D4", + "activityBar.background": "#181818", + "activityBar.border": "#2B2B2B", + "activityBar.foreground": "#D7D7D7", + "activityBar.inactiveForeground": "#868686", + "activityBarBadge.background": "#0078D4", + "activityBarBadge.foreground": "#FFFFFF", + "badge.background": "#616161", + "badge.foreground": "#F8F8F8", + "button.background": "#0078D4", + "button.border": "#FFFFFF12", + "button.foreground": "#FFFFFF", + "button.hoverBackground": "#026EC1", + "button.secondaryBackground": "#313131", + "button.secondaryForeground": "#CCCCCC", + "button.secondaryHoverBackground": "#3C3C3C", + "chat.slashCommandBackground": "#34414B", + "chat.slashCommandForeground": "#40A6FF", + "checkbox.background": "#313131", + "checkbox.border": "#3C3C3C", + "debugToolBar.background": "#181818", + "descriptionForeground": "#9D9D9D", + "dropdown.background": "#313131", + "dropdown.border": "#3C3C3C", + "dropdown.foreground": "#CCCCCC", + "dropdown.listBackground": "#1F1F1F", + "editor.background": "#1F1F1F", + "editor.findMatchBackground": "#9E6A03", + "editorGroup.border": "#FFFFFF17", + "editorGroupHeader.tabsBackground": "#181818", + "editorGroupHeader.tabsBorder": "#2B2B2B", + "editorGutter.addedBackground": "#2EA043", + "editorGutter.deletedBackground": "#F85149", + "editorGutter.modifiedBackground": "#0078D4", + "editorLineNumber.activeForeground": "#CCCCCC", + "editorLineNumber.foreground": "#6E7681", + "editorOverviewRuler.border": "#010409", + "editorWidget.background": "#202020", + "focusBorder": "#0078D4", + "foreground": "#CCCCCC", + "icon.foreground": "#CCCCCC", + "input.background": "#313131", + "input.border": "#3C3C3C", + "input.foreground": "#CCCCCC", + "input.placeholderForeground": "#9D9D9D", + "inputOption.activeBackground": "#2489DB82", + "inputOption.activeBorder": "#2488DB", + "keybindingLabel.foreground": "#CCCCCC", + "menu.background": "#1F1F1F", + "notificationCenterHeader.background": "#1F1F1F", + "notificationCenterHeader.foreground": "#CCCCCC", + "notifications.background": "#1F1F1F", + "notifications.border": "#2B2B2B", + "notifications.foreground": "#CCCCCC", + "panel.background": "#181818", + "panel.border": "#2B2B2B", + "panelInput.border": "#2B2B2B", + "panelTitle.activeBorder": "#0078D4", + "panelTitle.activeForeground": "#CCCCCC", + "panelTitle.inactiveForeground": "#9D9D9D", + "peekViewEditor.background": "#1F1F1F", + "peekViewEditor.matchHighlightBackground": "#BB800966", + "peekViewResult.background": "#1F1F1F", + "peekViewResult.matchHighlightBackground": "#BB800966", + "pickerGroup.border": "#3C3C3C", + "progressBar.background": "#0078D4", + "quickInput.background": "#222222", + "quickInput.foreground": "#CCCCCC", + "settings.dropdownBackground": "#313131", + "settings.dropdownBorder": "#3C3C3C", + "settings.headerForeground": "#FFFFFF", + "settings.modifiedItemIndicator": "#BB800966", + "sideBar.background": "#181818", + "sideBar.border": "#2B2B2B", + "sideBar.foreground": "#CCCCCC", + "sideBarSectionHeader.background": "#181818", + "sideBarSectionHeader.border": "#2B2B2B", + "sideBarSectionHeader.foreground": "#CCCCCC", + "sideBarTitle.foreground": "#CCCCCC", + "statusBar.background": "#181818", + "statusBar.border": "#2B2B2B", + "statusBar.debuggingBackground": "#0078D4", + "statusBar.debuggingForeground": "#FFFFFF", + "statusBar.focusBorder": "#0078D4", + "statusBar.foreground": "#CCCCCC", + "statusBar.noFolderBackground": "#1F1F1F", + "statusBarItem.focusBorder": "#0078D4", + "statusBarItem.prominentBackground": "#6E768166", + "statusBarItem.remoteBackground": "#0078D4", + "statusBarItem.remoteForeground": "#FFFFFF", + "tab.activeBackground": "#1F1F1F", + "tab.activeBorder": "#1F1F1F", + "tab.activeBorderTop": "#0078D4", + "tab.activeForeground": "#FFFFFF", + "tab.border": "#2B2B2B", + "tab.hoverBackground": "#1F1F1F", + "tab.inactiveBackground": "#181818", + "tab.inactiveForeground": "#9D9D9D", + "tab.unfocusedActiveBorder": "#1F1F1F", + "tab.unfocusedActiveBorderTop": "#2B2B2B", + "tab.unfocusedHoverBackground": "#1F1F1F", + "terminal.foreground": "#CCCCCC", + "terminal.tab.activeBorder": "#0078D4", + "textBlockQuote.background": "#2B2B2B", + "textBlockQuote.border": "#616161", + "textCodeBlock.background": "#2B2B2B", + "textLink.activeForeground": "#40A6FF", + "textLink.foreground": "#40A6FF", + "textSeparator.foreground": "#21262D", + "titleBar.activeBackground": "#181818", + "titleBar.activeForeground": "#CCCCCC", + "titleBar.border": "#2B2B2B", + "titleBar.inactiveBackground": "#1F1F1F", + "titleBar.inactiveForeground": "#9D9D9D", + "welcomePage.tileBackground": "#2B2B2B", + "welcomePage.progress.foreground": "#0078D4", + "widget.border": "#313131" + } +} diff --git a/ark/dark/highlighting.png b/ark/dark/highlighting.png new file mode 100644 index 0000000000..3015b602ab Binary files /dev/null and b/ark/dark/highlighting.png differ diff --git a/dev/arkdark/icon.png b/ark/dark/icon.png similarity index 100% rename from dev/arkdark/icon.png rename to ark/dark/icon.png diff --git a/ark/dark/injected.tmLanguage.json b/ark/dark/injected.tmLanguage.json new file mode 100644 index 0000000000..97ebb80120 --- /dev/null +++ b/ark/dark/injected.tmLanguage.json @@ -0,0 +1,141 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "scopeName": "source.arktype.injection.ts", + "injectionSelector": "L:source.ts - comment", + "patterns": [ + { + "include": "#arkDefinition" + } + ], + "repository": { + "arkDefinition": { + "contentName": "meta.embedded.arktype.definition", + "begin": "(([^\\)\\(\\s]*)?((\\.)?(type|scope|define|match|attest)|(\\.)(morph|and|or|when)))\\(", + "beginCaptures": { + "1": { + "name": "entity.name.function.ts" + }, + "4": { + "name": "punctuation.accessor" + }, + "6": { + "name": "punctuation.accessor" + } + }, + "end": "\\)", + "patterns": [ + { + "include": "#arkAll" + } + ] + }, + "arkQuoted": { + "match": "([\"'`])(.*?)(\\\\)*(\\1)", + "captures": { + "1": { + "name": "string.quoted" + }, + "2": { + "patterns": [ + { + "include": "#arkOperator" + }, + { + "include": "#arkRegex" + }, + { + "name": "keyword.operator", + "match": "[^ \\w]" + }, + { + "include": "source.ts#type" + } + ] + }, + "4": { + "name": "string.quoted" + } + } + }, + "arkRegex": { + "match": "(\\/)(.*?)(\\\\)*(\\1)", + "captures": { + "1": { "name": "keyword.operator" }, + "2": { + "patterns": [ + { + "name": "entity.name.type.ts", + "match": "[A-Za-z]" + }, + { + "name": "constant.numeric.decimal.ts", + "match": "[0-9]" + }, + { + "name": "keyword.operator", + "match": "[^ \\w]" + }, + { "include": "#arkOperator" }, + { "include": "#arkQuoted" } + ] + }, + "4": { "name": "keyword.operator" } + } + }, + "arkOperator": { + "name": "keyword.operator", + "match": "[<>=%`'&]|\"" + }, + "arkObject": { + "begin": "(\\s*)?\\{", + "end": "\\}", + "beginCaptures": { + "0": { + "name": "punctuation.definition.block.ts" + } + }, + "endCaptures": { + "0": { + "name": "punctuation.definition.block.ts" + } + }, + "patterns": [ + { + "match": "\\w+\\:", + "name": "meta.object-literal.key.ts" + }, + { + "include": "#arkAll" + } + ] + }, + "arkTuple": { + "begin": "(\\s*)?\\[", + "end": "\\]", + "patterns": [ + { + "include": "#arkAll" + } + ] + }, + "arkAll": { + "patterns": [ + { + "include": "#arkObject" + }, + { + "include": "#arkTuple" + }, + { + "include": "#arkQuoted" + }, + { + "include": "source.ts#comment" + }, + { + "include": "source.ts" + } + ] + } + } +} diff --git a/ark/dark/package.json b/ark/dark/package.json new file mode 100644 index 0000000000..11a8c5bf91 --- /dev/null +++ b/ark/dark/package.json @@ -0,0 +1,72 @@ +{ + "name": "arkdark", + "displayName": "ArkDark", + "description": "ArkType syntax highlighting and theme⛵", + "version": "4.0.1", + "publisher": "arktypeio", + "type": "module", + "scripts": { + "vspublish": "pnpx vsce publish" + }, + "repository": { + "type": "git", + "url": "https://github.com/arktypeio/arktype" + }, + "engines": { + "vscode": "^1.0.0" + }, + "icon": "icon.png", + "categories": [ + "Themes", + "Programming Languages" + ], + "extensionDependencies": [ + "usernamehw.errorlens" + ], + "contributes": { + "themes": [ + { + "label": "ArkDark", + "uiTheme": "vs-dark", + "path": "./color-theme.json" + } + ], + "grammars": [ + { + "injectTo": [ + "source.ts", + "source.ts.tsx", + "source.js", + "source.js.jsx" + ], + "scopeName": "source.arktype.injection.ts", + "path": "injected.tmLanguage.json" + } + ], + "configurationDefaults": { + "errorLens.followCursor": "closestProblem", + "errorLens.delay": 0, + "errorLens.editorHoverPartsEnabled": { + "buttonsEnabled": false + }, + "errorLens.replace": [ + { + "matcher": "[^]*\n([^\n]*)$", + "message": "$1" + }, + { + "matcher": "Argument of type '.*' is not assignable to parameter of type '\"(.*)\"'.", + "message": "$1" + }, + { + "matcher": "Type '.*' is not assignable to type '\"(.*)\"'.", + "message": "$1" + }, + { + "matcher": "Type '.*' is not assignable to type 'indexParseError<(.*)>'.", + "message": "$1" + } + ] + } + } +} diff --git a/ark/dark/tsWithArkType.tmLanguage.json b/ark/dark/tsWithArkType.tmLanguage.json new file mode 100644 index 0000000000..6f26de8be7 --- /dev/null +++ b/ark/dark/tsWithArkType.tmLanguage.json @@ -0,0 +1,3432 @@ +{ + "name": "TypeScript with ArkType", + "scopeName": "source.ts", + "fileTypes": ["ts"], + "patterns": [ + { + "include": "#directives" + }, + { + "include": "#statements" + }, + { + "name": "comment.line.shebang.ts", + "match": "\\A(#!).*(?=$)", + "captures": { + "1": { + "name": "punctuation.definition.comment.ts" + } + } + } + ], + "repository": { + "arkDefinition": { + "contentName": "meta.embedded.arktype.definition", + "begin": "(([^\\)\\(\\s]*)?((\\.)?(type|scope|define|match|attest)|(\\.)(morph|and|or|when)))\\(", + "beginCaptures": { + "1": { + "name": "entity.name.function.ts" + }, + "4": { + "name": "punctuation.accessor" + }, + "6": { + "name": "punctuation.accessor" + } + }, + "end": "\\)", + "patterns": [ + { + "include": "#arkAll" + } + ] + }, + "arkQuoted": { + "match": "([\"'`])(.*?)(\\\\)*(\\1)", + "captures": { + "1": { + "name": "string.quoted" + }, + "2": { + "patterns": [ + { + "include": "#arkOperator" + }, + { + "include": "#arkRegex" + }, + { + "name": "keyword.operator", + "match": "[^ \\w]" + }, + { + "include": "source.ts#type" + } + ] + }, + "4": { + "name": "string.quoted" + } + } + }, + "arkRegex": { + "match": "(\\/)(.*?)(\\\\)*(\\1)", + "captures": { + "1": { "name": "keyword.operator" }, + "2": { + "patterns": [ + { + "name": "entity.name.type.ts", + "match": "[A-Za-z]" + }, + { + "name": "constant.numeric.decimal.ts", + "match": "[0-9]" + }, + { + "name": "keyword.operator", + "match": "[^ \\w]" + }, + { "include": "#arkOperator" }, + { "include": "#arkQuoted" } + ] + }, + "4": { "name": "keyword.operator" } + } + }, + "arkOperator": { + "name": "keyword.operator", + "match": "[<>=%`'&]|\"" + }, + "arkObject": { + "begin": "(\\s*)?\\{", + "end": "\\}", + "beginCaptures": { + "0": { + "name": "punctuation.definition.block.ts" + } + }, + "endCaptures": { + "0": { + "name": "punctuation.definition.block.ts" + } + }, + "patterns": [ + { + "match": "\\w+\\:", + "name": "meta.object-literal.key.ts" + }, + { + "include": "#arkAll" + } + ] + }, + "arkTuple": { + "begin": "(\\s*)?\\[", + "end": "\\]", + "patterns": [ + { + "include": "#arkAll" + } + ] + }, + "arkAll": { + "patterns": [ + { + "include": "#arkObject" + }, + { + "include": "#arkTuple" + }, + { + "include": "#arkQuoted" + }, + { + "include": "source.ts#comment" + }, + { + "include": "source.ts" + } + ] + }, + "statements": { + "patterns": [ + { + "include": "#string" + }, + { + "include": "#template" + }, + { + "include": "#comment" + }, + { + "include": "#literal" + }, + { + "include": "#declaration" + }, + { + "include": "#switch-statement" + }, + { + "include": "#for-loop" + }, + { + "include": "#after-operator-block" + }, + { + "include": "#decl-block" + }, + { + "include": "#control-statement" + }, + { + "include": "#expression" + }, + { + "include": "#punctuation-semicolon" + } + ] + }, + "var-expr": { + "name": "meta.var.expr.ts", + "begin": "(?) | ((<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\(([^()]|\\([^()]*\\))*\\)(\\s*:\\s*(.)*)?\\s*=>)) ) | (:\\s*( (<) | ([(]\\s*( ([)]) | (\\.\\.\\.) | ([_$[:alnum:]]+\\s*( ([:,?=])| ([)]\\s*=>) )) ))) ))", + "beginCaptures": { + "1": { + "name": "entity.name.function.ts" + } + }, + "end": "(?=$|[;,=}]|(\\s+(of|in)\\s+))", + "patterns": [ + { + "include": "#type-annotation" + }, + { + "include": "#string" + }, + { + "include": "#comment" + } + ] + }, + { + "name": "meta.var-single-variable.expr.ts", + "begin": "([_$[:alpha:]][_$[:alnum:]]*)", + "beginCaptures": { + "1": { + "name": "variable.other.readwrite.ts" + } + }, + "end": "(?=$|[;,=}]|(\\s+(of|in)\\s+))", + "patterns": [ + { + "include": "#type-annotation" + }, + { + "include": "#string" + }, + { + "include": "#comment" + } + ] + } + ] + }, + "destructuring-variable": { + "patterns": [ + { + "name": "meta.object-binding-pattern-variable.ts", + "begin": "(?) | ((<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\(([^()]|\\([^()]*\\))*\\)(\\s*:\\s*(.)*)?\\s*=>)) ) | (:\\s*( (<) | ([(]\\s*( ([)]) | (\\.\\.\\.) | ([_$[:alnum:]]+\\s*( ([:,?=])| ([)]\\s*=>) )) ))) ))" + }, + { + "name": "variable.object.property.ts", + "match": "[_$[:alpha:]][_$[:alnum:]]*" + }, + { + "name": "keyword.operator.optional.ts", + "match": "\\?" + } + ] + } + ] + }, + "method-declaration": { + "name": "meta.method.declaration.ts", + "begin": "(?) | ((<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\(([^()]|\\([^()]*\\))*\\)(\\s*:\\s*(.)*)?\\s*=>)) ) | (:\\s*( (<) | ([(]\\s*( ([)]) | (\\.\\.\\.) | ([_$[:alnum:]]+\\s*( ([:,?=])| ([)]\\s*=>) )) ))) ))", + "captures": { + "1": { + "name": "storage.modifier.ts" + }, + "2": { + "name": "storage.modifier.ts" + }, + "3": { + "name": "keyword.operator.rest.ts" + }, + "4": { + "name": "entity.name.function.ts" + }, + "5": { + "name": "keyword.operator.optional.ts" + } + } + }, + { + "match": "(?:\\s*\\b(readonly)\\s+)?(?:\\s*\\b(public|private|protected)\\s+)?(\\.\\.\\.)?\\s*(?])|(?<=[\\}>\\]\\)]|[_$[:alpha:]])\\s*(?=\\{)", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#type" + } + ] + }, + "type": { + "name": "meta.type.ts", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#string" + }, + { + "include": "#numeric-literal" + }, + { + "include": "#type-primitive" + }, + { + "include": "#type-builtin-literals" + }, + { + "include": "#type-parameters" + }, + { + "include": "#type-tuple" + }, + { + "include": "#type-object" + }, + { + "include": "#type-operators" + }, + { + "include": "#type-fn-type-parameters" + }, + { + "include": "#type-paren-or-function-parameters" + }, + { + "include": "#type-function-return-type" + }, + { + "include": "#type-name" + } + ] + }, + "function-parameters": { + "name": "meta.parameters.ts", + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "punctuation.definition.parameters.begin.ts" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.parameters.end.ts" + } + }, + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#decorator" + }, + { + "include": "#destructuring-parameter" + }, + { + "include": "#parameter-name" + }, + { + "include": "#type-annotation" + }, + { + "include": "#variable-initializer" + }, + { + "name": "punctuation.separator.parameter.ts", + "match": "," + } + ] + }, + "type-primitive": { + "name": "support.type.primitive.ts", + "match": "(?)\\s*(?=\\()", + "end": "(?<=\\))", + "patterns": [ + { + "include": "#function-parameters" + } + ] + }, + { + "name": "meta.type.function.ts", + "begin": "(?x)( (?= [(]\\s*( ([)]) | (\\.\\.\\.) | ([_$[:alnum:]]+\\s*( ([:,?=])| ([)]\\s*=>) )) ) ) )", + "end": "(?<=\\))", + "patterns": [ + { + "include": "#function-parameters" + } + ] + } + ] + }, + "type-operators": { + "patterns": [ + { + "include": "#typeof-operator" + }, + { + "name": "keyword.operator.type.ts", + "match": "[&|]" + }, + { + "name": "keyword.operator.expression.keyof.ts", + "match": "(?", + "beginCaptures": { + "0": { + "name": "storage.type.function.arrow.ts" + } + }, + "end": "(?)(?=[,\\]\\)\\{\\}=;>]|//|$)", + "patterns": [ + { + "include": "#comment" + }, + { + "name": "meta.object.type.ts", + "begin": "(?<==>)\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.block.ts" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.ts" + } + }, + "patterns": [ + { + "include": "#type-object-members" + } + ] + }, + { + "include": "#type-predicate-operator" + }, + { + "include": "#type" + } + ] + }, + "type-tuple": { + "name": "meta.type.tuple.ts", + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "meta.brace.square.ts" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "meta.brace.square.ts" + } + }, + "patterns": [ + { + "include": "#type" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "type-name": { + "patterns": [ + { + "match": "([_$[:alpha:]][_$[:alnum:]]*)\\s*(\\.)", + "captures": { + "1": { + "name": "entity.name.type.module.ts" + }, + "2": { + "name": "punctuation.accessor.ts" + } + } + }, + { + "name": "entity.name.type.ts", + "match": "[_$[:alpha:]][_$[:alnum:]]*" + } + ] + }, + "type-parameters": { + "name": "meta.type.parameters.ts", + "begin": "(<)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.typeparameters.begin.ts" + } + }, + "end": "(?=$)|(>)", + "endCaptures": { + "1": { + "name": "punctuation.definition.typeparameters.end.ts" + } + }, + "patterns": [ + { + "include": "#comment" + }, + { + "name": "storage.modifier.ts", + "match": "(?]|\\<[^<>]+\\>)+>\\s*)?\\()", + "captures": { + "1": { + "name": "punctuation.accessor.ts" + }, + "2": { + "name": "support.constant.dom.ts" + }, + "3": { + "name": "support.variable.property.dom.ts" + } + } + }, + { + "name": "support.class.node.ts", + "match": "(?x)(?]|\\<[^<>]+\\>)+>\\s*)?\\()", + "end": "(?<=\\))(?!(\\.\\s*)?([_$[:alpha:]][_$[:alnum:]]*)\\s*(<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\()", + "patterns": [ + { + "include": "#support-objects" + }, + { + "name": "punctuation.accessor.ts", + "match": "\\." + }, + { + "name": "entity.name.function.ts", + "match": "([_$[:alpha:]][_$[:alnum:]]*)" + }, + { + "include": "#comment" + }, + { + "name": "meta.type.parameters.ts", + "begin": "\\<", + "beginCaptures": { + "0": { + "name": "punctuation.definition.typeparameters.begin.ts" + } + }, + "end": "\\>", + "endCaptures": { + "0": { + "name": "punctuation.definition.typeparameters.end.ts" + } + }, + "patterns": [ + { + "include": "#type" + }, + { + "include": "#punctuation-comma" + } + ] + }, + { + "include": "#paren-expression" + } + ] + }, + "identifiers": { + "patterns": [ + { + "name": "support.class.ts", + "match": "([_$[:alpha:]][_$[:alnum:]]*)(?=\\s*\\.\\s*prototype\\b(?!\\$))" + }, + { + "match": "(?x)(\\.)\\s*(?:\n ([[:upper:]][_$[:digit:][:upper:]]*) |\n ([_$[:alpha:]][_$[:alnum:]]*)\n)(?=\\s*\\.\\s*[_$[:alpha:]][_$[:alnum:]]*)", + "captures": { + "1": { + "name": "punctuation.accessor.ts" + }, + "2": { + "name": "constant.other.object.property.ts" + }, + "3": { + "name": "variable.other.object.property.ts" + } + } + }, + { + "match": "(?x)(?:(\\.)\\s*)?([_$[:alpha:]][_$[:alnum:]]*)(?=\\s*=\\s*( (async\\s+)|(function\\s*[(<])|(function\\s+)| ([_$[:alpha:]][_$[:alnum:]]*\\s*=>)| ((<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\(([^()]|\\([^()]*\\))*\\)(\\s*:\\s*(.)*)?\\s*=>)))", + "captures": { + "1": { + "name": "punctuation.accessor.ts" + }, + "2": { + "name": "entity.name.function.ts" + } + } + }, + { + "match": "(\\.)\\s*([[:upper:]][_$[:digit:][:upper:]]*)(?![_$[:alnum:]])", + "captures": { + "1": { + "name": "punctuation.accessor.ts" + }, + "2": { + "name": "constant.other.property.ts" + } + } + }, + { + "match": "(\\.)\\s*([_$[:alpha:]][_$[:alnum:]]*)", + "captures": { + "1": { + "name": "punctuation.accessor.ts" + }, + "2": { + "name": "variable.other.property.ts" + } + } + }, + { + "match": "(?x)(?:\n ([[:upper:]][_$[:digit:][:upper:]]*) |\n ([_$[:alpha:]][_$[:alnum:]]*)\n)(?=\\s*\\.\\s*[_$[:alpha:]][_$[:alnum:]]*)", + "captures": { + "1": { + "name": "constant.other.object.ts" + }, + "2": { + "name": "variable.other.object.ts" + } + } + }, + { + "name": "constant.other.ts", + "match": "([[:upper:]][_$[:digit:][:upper:]]*)(?![_$[:alnum:]])" + }, + { + "name": "variable.other.readwrite.ts", + "match": "[_$[:alpha:]][_$[:alnum:]]*" + } + ] + }, + "cast": { + "name": "cast.expr.ts", + "begin": "(?:(?<=return|throw|yield|await|default|[=(,:>*]))\\s*(<)(?!", + "endCaptures": { + "0": { + "name": "meta.brace.angle.ts" + } + }, + "patterns": [ + { + "include": "#type" + } + ] + }, + "new-expr": { + "name": "new.expr.ts", + "begin": "(?)| ((<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\(([^()]|\\([^()]*\\))*\\)(\\s*:\\s*(.)*)?\\s*=>))))", + "beginCaptures": { + "0": { + "name": "meta.object-literal.key.ts" + }, + "1": { + "name": "entity.name.function.ts" + }, + "2": { + "name": "punctuation.separator.key-value.ts" + } + }, + "end": "(?=,|\\})", + "patterns": [ + { + "include": "#expression" + } + ] + }, + { + "name": "meta.object.member.ts", + "begin": "(?:[_$[:alpha:]][_$[:alnum:]]*)\\s*(:)", + "beginCaptures": { + "0": { + "name": "meta.object-literal.key.ts" + }, + "1": { + "name": "punctuation.separator.key-value.ts" + } + }, + "end": "(?=,|\\})", + "patterns": [ + { + "include": "#expression" + } + ] + }, + { + "name": "meta.object.member.ts", + "begin": "\\.\\.\\.", + "beginCaptures": { + "0": { + "name": "keyword.operator.spread.ts" + } + }, + "end": "(?=,|\\})", + "patterns": [ + { + "include": "#expression" + } + ] + }, + { + "name": "meta.object.member.ts", + "match": "([_$[:alpha:]][_$[:alnum:]]*)\\s*(?=,|\\}|$)", + "captures": { + "1": { + "name": "variable.other.readwrite.ts" + } + } + }, + { + "include": "#punctuation-comma" + } + ] + }, + "expression-operators": { + "patterns": [ + { + "name": "keyword.control.flow.ts", + "match": "(?>=|>>>=|\\|=" + }, + { + "name": "keyword.operator.bitwise.shift.ts", + "match": "<<|>>>|>>" + }, + { + "name": "keyword.operator.comparison.ts", + "match": "===|!==|==|!=" + }, + { + "name": "keyword.operator.relational.ts", + "match": "<=|>=|<>|<|>" + }, + { + "name": "keyword.operator.logical.ts", + "match": "\\!|&&|\\|\\|" + }, + { + "name": "keyword.operator.bitwise.ts", + "match": "\\&|~|\\^|\\|" + }, + { + "name": "keyword.operator.assignment.ts", + "match": "\\=" + }, + { + "name": "keyword.operator.decrement.ts", + "match": "--" + }, + { + "name": "keyword.operator.increment.ts", + "match": "\\+\\+" + }, + { + "name": "keyword.operator.arithmetic.ts", + "match": "%|\\*|/|-|\\+" + }, + { + "match": "(?<=[_$[:alnum:]])\\s*(/)(?![/*])", + "captures": { + "1": { + "name": "keyword.operator.arithmetic.ts" + } + } + } + ] + }, + "typeof-operator": { + "name": "keyword.operator.expression.typeof.ts", + "match": "(?)", + "captures": { + "1": { + "name": "storage.modifier.async.ts" + }, + "2": { + "name": "variable.parameter.ts" + } + } + }, + { + "name": "meta.arrow.ts", + "begin": "(?x)\\s*(?=(<([^<>]|\\<[^<>]+\\>)+>\\s*)?\\(([^()]|\\([^()]*\\))*\\)(\\s*:\\s*(.)*)?\\s*=>)", + "end": "(?==>)", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#type-parameters" + }, + { + "include": "#function-parameters" + }, + { + "include": "#arrow-return-type" + } + ] + }, + { + "name": "meta.arrow.ts", + "begin": "=>", + "beginCaptures": { + "0": { + "name": "storage.type.function.arrow.ts" + } + }, + "end": "(?<=\\})|((?!\\{)(?=\\S))", + "patterns": [ + { + "include": "#decl-block" + }, + { + "include": "#expression" + } + ] + } + ] + }, + "arrow-return-type": { + "name": "meta.return.type.arrow.ts", + "begin": "(?<=\\))\\s*(:)", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.ts" + } + }, + "end": "(?|;|//))", + "patterns": [ + { + "include": "#type-predicate-operator" + }, + { + "include": "#type" + } + ] + }, + "punctuation-comma": { + "name": "punctuation.separator.comma.ts", + "match": "," + }, + "punctuation-semicolon": { + "name": "punctuation.terminator.statement.ts", + "match": ";" + }, + "punctuation-accessor": { + "name": "punctuation.accessor.ts", + "match": "\\." + }, + "paren-expression": { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "meta.brace.round.ts" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "meta.brace.round.ts" + } + }, + "patterns": [ + { + "include": "#expression" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "qstring-double": { + "name": "string.quoted.double.ts", + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.ts" + } + }, + "end": "(\")|((?:[^\\\\\\n])$)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.ts" + }, + "2": { + "name": "invalid.illegal.newline.ts" + } + }, + "patterns": [ + { + "include": "#string-character-escape" + } + ] + }, + "qstring-single": { + "name": "string.quoted.single.ts", + "begin": "'", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.ts" + } + }, + "end": "(\\')|((?:[^\\\\\\n])$)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.ts" + }, + "2": { + "name": "invalid.illegal.newline.ts" + } + }, + "patterns": [ + { + "include": "#string-character-escape" + } + ] + }, + "regex": { + "patterns": [ + { + "name": "string.regex.ts", + "begin": "(?<=[=(:,\\[?+!]|return|case|=>|&&|\\|\\||\\*\\/)\\s*(/)(?![/*])(?=(?:[^/\\\\\\[]|\\\\.|\\[([^\\]\\\\]|\\\\.)+\\])+/(?![/*])[gimy]*(?!\\s*[a-zA-Z0-9_$]))", + "beginCaptures": { + "1": { + "name": "punctuation.definition.string.begin.ts" + } + }, + "end": "(/)([gimuy]*)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.ts" + }, + "2": { + "name": "keyword.other.ts" + } + }, + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "name": "string.regex.ts", + "begin": "/(?![/*])(?=(?:[^/\\\\\\[]|\\\\.|\\[([^\\]\\\\]|\\\\.)+\\])+/(?![/*])[gimy]*(?!\\s*[a-zA-Z0-9_$]))", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.ts" + } + }, + "end": "(/)([gimuy]*)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.ts" + }, + "2": { + "name": "keyword.other.ts" + } + }, + "patterns": [ + { + "include": "#regexp" + } + ] + } + ] + }, + "regexp": { + "patterns": [ + { + "name": "keyword.control.anchor.regexp", + "match": "\\\\[bB]|\\^|\\$" + }, + { + "name": "keyword.other.back-reference.regexp", + "match": "\\\\[1-9]\\d*" + }, + { + "name": "keyword.operator.quantifier.regexp", + "match": "[?+*]|\\{(\\d+,\\d+|\\d+,|,\\d+|\\d+)\\}\\??" + }, + { + "name": "keyword.operator.or.regexp", + "match": "\\|" + }, + { + "name": "meta.group.assertion.regexp", + "begin": "(\\()((\\?=)|(\\?!))", + "beginCaptures": { + "1": { + "name": "punctuation.definition.group.regexp" + }, + "2": { + "name": "punctuation.definition.group.assertion.regexp" + }, + "3": { + "name": "meta.assertion.look-ahead.regexp" + }, + "4": { + "name": "meta.assertion.negative-look-ahead.regexp" + } + }, + "end": "(\\))", + "endCaptures": { + "1": { + "name": "punctuation.definition.group.regexp" + } + }, + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "name": "meta.group.regexp", + "begin": "\\((\\?:)?", + "beginCaptures": { + "0": { + "name": "punctuation.definition.group.regexp" + }, + "1": { + "name": "punctuation.definition.group.capture.regexp" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.group.regexp" + } + }, + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "name": "constant.other.character-class.set.regexp", + "begin": "(\\[)(\\^)?", + "beginCaptures": { + "1": { + "name": "punctuation.definition.character-class.regexp" + }, + "2": { + "name": "keyword.operator.negation.regexp" + } + }, + "end": "(\\])", + "endCaptures": { + "1": { + "name": "punctuation.definition.character-class.regexp" + } + }, + "patterns": [ + { + "name": "constant.other.character-class.range.regexp", + "match": "(?:.|(\\\\(?:[0-7]{3}|x\\h\\h|u\\h\\h\\h\\h))|(\\\\c[A-Z])|(\\\\.))\\-(?:[^\\]\\\\]|(\\\\(?:[0-7]{3}|x\\h\\h|u\\h\\h\\h\\h))|(\\\\c[A-Z])|(\\\\.))", + "captures": { + "1": { + "name": "constant.character.numeric.regexp" + }, + "2": { + "name": "constant.character.control.regexp" + }, + "3": { + "name": "constant.character.escape.backslash.regexp" + }, + "4": { + "name": "constant.character.numeric.regexp" + }, + "5": { + "name": "constant.character.control.regexp" + }, + "6": { + "name": "constant.character.escape.backslash.regexp" + } + } + }, + { + "include": "#regex-character-class" + } + ] + }, + { + "include": "#regex-character-class" + } + ] + }, + "regex-character-class": { + "patterns": [ + { + "name": "constant.other.character-class.regexp", + "match": "\\\\[wWsSdDtrnvf]|\\." + }, + { + "name": "constant.character.numeric.regexp", + "match": "\\\\([0-7]{3}|x\\h\\h|u\\h\\h\\h\\h)" + }, + { + "name": "constant.character.control.regexp", + "match": "\\\\c[A-Z]" + }, + { + "name": "constant.character.escape.backslash.regexp", + "match": "\\\\." + } + ] + }, + "string": { + "patterns": [ + { + "include": "#qstring-single" + }, + { + "include": "#qstring-double" + } + ] + }, + "template": { + "name": "string.template.ts", + "begin": "([_$[:alpha:]][_$[:alnum:]]*)?(`)", + "beginCaptures": { + "1": { + "name": "entity.name.function.tagged-template.ts" + }, + "2": { + "name": "punctuation.definition.string.template.begin.ts" + } + }, + "end": "`", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.template.end.ts" + } + }, + "patterns": [ + { + "include": "#template-substitution-element" + }, + { + "include": "#string-character-escape" + } + ] + }, + "string-character-escape": { + "name": "constant.character.escape.ts", + "match": "\\\\(x\\h{2}|[0-2][0-7]{0,2}|3[0-6][0-7]?|37[0-7]?|[4-7][0-7]?|.|$)" + }, + "template-substitution-element": { + "name": "meta.template.expression.ts", + "begin": "\\$\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.template-expression.begin.ts" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.template-expression.end.ts" + } + }, + "patterns": [ + { + "include": "#expression" + } + ] + }, + "literal": { + "name": "literal.ts", + "patterns": [ + { + "include": "#numeric-literal" + }, + { + "include": "#boolean-literal" + }, + { + "include": "#null-literal" + }, + { + "include": "#undefined-literal" + }, + { + "include": "#numericConstant-literal" + }, + { + "include": "#array-literal" + }, + { + "include": "#this-literal" + }, + { + "include": "#super-literal" + } + ] + }, + "array-literal": { + "name": "meta.array.literal.ts", + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "meta.brace.square.ts" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "meta.brace.square.ts" + } + }, + "patterns": [ + { + "include": "#expression" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "numeric-literal": { + "patterns": [ + { + "name": "constant.numeric.hex.ts", + "match": "\\b(?\\s*$)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.comment.ts" + } + }, + "end": "(?=$)", + "patterns": [ + { + "name": "meta.tag.ts", + "begin": "(<)(reference|amd-dependency|amd-module)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.directive.ts" + }, + "2": { + "name": "entity.name.tag.directive.ts" + } + }, + "end": "/>", + "endCaptures": { + "0": { + "name": "punctuation.definition.tag.directive.ts" + } + }, + "patterns": [ + { + "name": "entity.other.attribute-name.directive.ts", + "match": "path|types|no-default-lib|name" + }, + { + "name": "keyword.operator.assignment.ts", + "match": "=" + }, + { + "include": "#string" + } + ] + } + ] + }, + "docblock": { + "patterns": [ + { + "name": "storage.type.class.jsdoc", + "match": "(? # {Array} or {Object} type application (optional .)\n )\n (?:\n [\\.|~] # {Foo.bar} namespaced, {string|number} multiple, {Foo~bar} class-specific callback\n [a-zA-Z_$]+\n (?:\n (?:\n [\\w$]*\n (?:\\[\\])? # {(string|number[])} type application, a string or an array of numbers\n ) |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n )*\n \\) |\n [a-zA-Z_$]+\n (?:\n (?:\n [\\w$]*\n (?:\\[\\])? # {string[]|number} type application, an array of strings or a number\n ) |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n (?:\n [\\.|~] # {Foo.bar} namespaced, {string|number} multiple, {Foo~bar} class-specific callback\n [a-zA-Z_$]+\n (?:\n [\\w$]* |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n )*\n )\n # Check for suffix\n (?:\\[\\])? # {string[]} type application, an array of strings\n =? # {string=} optional parameter\n)})\n\\s+\n(\n \\[ # [foo] optional parameter\n \\s*\n (?:\n [a-zA-Z_$][\\w$]*\n (?:\n (?:\\[\\])? # Foo[].bar properties within an array\n \\. # Foo.Bar namespaced parameter\n [a-zA-Z_$][\\w$]*\n )*\n (?:\n \\s*\n = # [foo=bar] Default parameter value\n \\s*\n [\\w$\\s]*\n )?\n )\n \\s*\n \\] |\n (?:\n [a-zA-Z_$][\\w$]*\n (?:\n (?:\\[\\])? # Foo[].bar properties within an array\n \\. # Foo.Bar namespaced parameter\n [a-zA-Z_$][\\w$]*\n )*\n )?\n)\n\\s+\n(?:-\\s+)? # optional hyphen before the description\n((?:(?!\\*\\/).)*) # The type description", + "captures": { + "0": { + "name": "other.meta.jsdoc" + }, + "1": { + "name": "entity.name.type.instance.jsdoc" + }, + "2": { + "name": "variable.other.jsdoc" + }, + "3": { + "name": "other.description.jsdoc" + } + } + }, + { + "match": "(?x)\n({(?:\n \\* | # {*} any type\n \\? | # {?} unknown type\n\n (?: # Check for a prefix\n \\? | # {?string} nullable type\n ! | # {!string} non-nullable type\n \\.{3} # {...string} variable number of parameters\n )?\n\n (?:\n \\( # Opening bracket of multiple types with parenthesis {(string|number)}\n [a-zA-Z_$]+\n (?:\n [\\w$]* |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n (?:\n [\\.|~] # {Foo.bar} namespaced, {string|number} multiple, {Foo~bar} class-specific callback\n [a-zA-Z_$]+\n (?:\n [\\w$]* |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n )*\n \\) |\n [a-zA-Z_$]+\n (?:\n [\\w$]* |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n (?:\n [\\.|~] # {Foo.bar} namespaced, {string|number} multiple, {Foo~bar} class-specific callback\n [a-zA-Z_$]+\n (?:\n [\\w$]* |\n \\.?<[\\w$]+(?:,\\s+[\\w$]+)*> # {Array} or {Object} type application (optional .)\n )\n )*\n )\n # Check for suffix\n (?:\\[\\])? # {string[]} type application, an array of strings\n =? # {string=} optional parameter\n)})\n\\s+\n(?:-\\s+)? # optional hyphen before the description\n((?:(?!\\*\\/).)*) # The type description", + "captures": { + "0": { + "name": "other.meta.jsdoc" + }, + "1": { + "name": "entity.name.type.instance.jsdoc" + }, + "2": { + "name": "other.description.jsdoc" + } + } + } + ] + } + }, + "version": "https://github.com/Microsoft/TypeScript-TmLanguage/commit/4d0bdebb93aadc25ecbb903ebc897e9cd5fab69c" +} diff --git a/dev/arktype.io/CNAME b/ark/docs/CNAME similarity index 100% rename from dev/arktype.io/CNAME rename to ark/docs/CNAME diff --git a/ark/docs/README.md b/ark/docs/README.md new file mode 100644 index 0000000000..f9d358dc86 --- /dev/null +++ b/ark/docs/README.md @@ -0,0 +1,5 @@ +# arktype.io + +Source code for ArkType's docs at [arktype.io](https://arktype.io) + +Built with [Starlight](https://starlight.astro.build/) diff --git a/ark/docs/astro.config.mjs b/ark/docs/astro.config.mjs new file mode 100644 index 0000000000..6d3d3f71e6 --- /dev/null +++ b/ark/docs/astro.config.mjs @@ -0,0 +1,40 @@ +import react from "@astrojs/react" +import starlight from "@astrojs/starlight" +import { defineConfig } from "astro/config" + +// https://astro.build/config +export default defineConfig({ + outDir: "out", + integrations: [ + starlight({ + title: "ArkType", + logo: { + src: "./src/assets/logo.svg", + replacesTitle: true + }, + customCss: ["./src/styles.css"], + social: { + twitch: "https://twitch.tv/arktypeio", + twitter: "https://twitter.com/arktypeio", + discord: "https://arktype.io/discord", + github: "https://github.com/arktypeio/arktype" + }, + sidebar: [ + { + label: "Intro", + items: [{ label: "Why ArkType?", link: "/intro/why/" }] + }, + { + label: "Reference", + items: [ + { label: "Your first type", link: "/reference/your-first-type/" }, + { label: "Scopes", link: "/reference/scopes/" }, + { label: "Cheat sheet", link: "/reference/cheat-sheet/" } + ] + } + ] + }), + react() + ], + site: "https://arktype.io" +}) diff --git a/ark/docs/package.json b/ark/docs/package.json new file mode 100644 index 0000000000..6d8e783488 --- /dev/null +++ b/ark/docs/package.json @@ -0,0 +1,33 @@ +{ + "name": "docs", + "private": true, + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@arktype/util": "workspace:*", + "@astrojs/starlight": "0.21.1", + "@astrojs/react": "3.0.10", + "@monaco-editor/react": "4.6.0", + "monaco-editor": "0.47.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" + }, + "devDependencies": { + "@types/react": "18.2.64", + "@types/react-dom": "18.2.21" + } +} diff --git a/ark/docs/public/favicon.svg b/ark/docs/public/favicon.svg new file mode 100644 index 0000000000..e96c892243 --- /dev/null +++ b/ark/docs/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/dev/arktype.io/static/img/HowToUpdateArktypeGifs.md b/ark/docs/src/assets/HowToUpdateArktypeGifs.md similarity index 84% rename from dev/arktype.io/static/img/HowToUpdateArktypeGifs.md rename to ark/docs/src/assets/HowToUpdateArktypeGifs.md index 9971d63c96..895dbc276a 100644 --- a/dev/arktype.io/static/img/HowToUpdateArktypeGifs.md +++ b/ark/docs/src/assets/HowToUpdateArktypeGifs.md @@ -1,9 +1,9 @@ # How to update these GIFs 1. Used modified VSCode: - - Deleted fileName navbar and added `margin-top: 24px` to the editor from devtools within VsCode - - Enabled Quokka, but disabled "Show Expression Value on Select" (this has to be done every time Quokka is restarted) - - Applied these settings: + - Deleted fileName navbar and added `margin-top: 24px` to the editor from devtools within VsCode + - Enabled Quokka, but disabled "Show Expression Value on Select" (this has to be done every time Quokka is restarted) + - Applied these settings: ```ts // DEMO ONLY (revert) diff --git a/dev/arktype.io/static/img/boat.svg b/ark/docs/src/assets/boat.svg similarity index 90% rename from dev/arktype.io/static/img/boat.svg rename to ark/docs/src/assets/boat.svg index 670bfd8373..d6c5070ea7 100644 --- a/dev/arktype.io/static/img/boat.svg +++ b/ark/docs/src/assets/boat.svg @@ -1,24 +1,26 @@ - - + + - - - - - - - + - + - - \ No newline at end of file diff --git a/ark/docs/src/assets/bun.svg b/ark/docs/src/assets/bun.svg new file mode 100644 index 0000000000..a26d889406 --- /dev/null +++ b/ark/docs/src/assets/bun.svg @@ -0,0 +1,49 @@ + + Bun Logo + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dev/arktype.io/static/img/integrationLogos/chromium.svg b/ark/docs/src/assets/chromium.svg similarity index 100% rename from dev/arktype.io/static/img/integrationLogos/chromium.svg rename to ark/docs/src/assets/chromium.svg diff --git a/dev/arktype.io/static/img/integrationLogos/deno.svg b/ark/docs/src/assets/deno.svg similarity index 100% rename from dev/arktype.io/static/img/integrationLogos/deno.svg rename to ark/docs/src/assets/deno.svg diff --git a/dev/arktype.io/static/img/github.svg b/ark/docs/src/assets/github.svg similarity index 100% rename from dev/arktype.io/static/img/github.svg rename to ark/docs/src/assets/github.svg diff --git a/dev/arktype.io/static/img/githubDark.svg b/ark/docs/src/assets/githubDark.svg similarity index 100% rename from dev/arktype.io/static/img/githubDark.svg rename to ark/docs/src/assets/githubDark.svg diff --git a/ark/docs/src/assets/intellij.svg b/ark/docs/src/assets/intellij.svg new file mode 100644 index 0000000000..0e6a001690 --- /dev/null +++ b/ark/docs/src/assets/intellij.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dev/arktype.io/static/img/integrationLogos/javascript.svg b/ark/docs/src/assets/js.svg similarity index 100% rename from dev/arktype.io/static/img/integrationLogos/javascript.svg rename to ark/docs/src/assets/js.svg diff --git a/dev/arktype.io/static/img/logo.png b/ark/docs/src/assets/logo.png similarity index 100% rename from dev/arktype.io/static/img/logo.png rename to ark/docs/src/assets/logo.png diff --git a/dev/arktype.io/static/img/logo.svg b/ark/docs/src/assets/logo.svg similarity index 96% rename from dev/arktype.io/static/img/logo.svg rename to ark/docs/src/assets/logo.svg index 49fecd6491..e96c892243 100644 --- a/dev/arktype.io/static/img/logo.svg +++ b/ark/docs/src/assets/logo.svg @@ -1,7 +1,7 @@ - + diff --git a/dev/arktype.io/static/img/logoTransparent.png b/ark/docs/src/assets/logoTransparent.png similarity index 100% rename from dev/arktype.io/static/img/logoTransparent.png rename to ark/docs/src/assets/logoTransparent.png diff --git a/dev/arktype.io/static/img/logoTransparent.svg b/ark/docs/src/assets/logoTransparent.svg similarity index 100% rename from dev/arktype.io/static/img/logoTransparent.svg rename to ark/docs/src/assets/logoTransparent.svg diff --git a/ark/docs/src/assets/neovim.svg b/ark/docs/src/assets/neovim.svg new file mode 100644 index 0000000000..82606c5ea3 --- /dev/null +++ b/ark/docs/src/assets/neovim.svg @@ -0,0 +1,32 @@ + + + neovim-mark@2x + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ark/docs/src/assets/node.svg b/ark/docs/src/assets/node.svg new file mode 100644 index 0000000000..5a3396c0ec --- /dev/null +++ b/ark/docs/src/assets/node.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dev/arktype.io/static/img/npm.svg b/ark/docs/src/assets/npm.svg similarity index 100% rename from dev/arktype.io/static/img/npm.svg rename to ark/docs/src/assets/npm.svg diff --git a/dev/arktype.io/static/img/integrationLogos/typescript.svg b/ark/docs/src/assets/ts.svg similarity index 100% rename from dev/arktype.io/static/img/integrationLogos/typescript.svg rename to ark/docs/src/assets/ts.svg diff --git a/dev/arktype.io/static/img/integrationLogos/vscode.svg b/ark/docs/src/assets/vscode.svg similarity index 95% rename from dev/arktype.io/static/img/integrationLogos/vscode.svg rename to ark/docs/src/assets/vscode.svg index 6eb6c40afd..42949c4ef4 100644 --- a/dev/arktype.io/static/img/integrationLogos/vscode.svg +++ b/ark/docs/src/assets/vscode.svg @@ -1,5 +1,5 @@ - + @@ -7,16 +7,16 @@ + fill="#0065A9" /> + fill="#007ACC" /> + fill="#1F9CF0" /> { + useEffect(() => { + const boatContainer = document.createElement("div") + const BOB_HEIGHT_PX = 2 + const BOB_WIDTH_PX = 16 + const width = window.innerWidth + const loopDuration = width / BOB_WIDTH_PX + const bobFrames: number[] = [] + for (let i = 0; i < loopDuration; i++) + bobFrames.push(i % 2 ? BOB_HEIGHT_PX : 0) + + createRoot( + document.getElementsByClassName("header")[0].appendChild(boatContainer) + ).render() + return () => { + boatContainer.removeChild(boatContainer) + } + }) + return null +} + +type BoatProps = { + loopDuration: number + bobFrames: number[] +} + +const Boat = ({ loopDuration, bobFrames }: BoatProps) => ( + + + +) diff --git a/ark/docs/src/components/HeroContents.tsx b/ark/docs/src/components/HeroContents.tsx new file mode 100644 index 0000000000..e9cd9c1ea8 --- /dev/null +++ b/ark/docs/src/components/HeroContents.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { FloatYourBoat } from "./FloatYourBoat.js" +import { PlatformCloud } from "./PlatformCloud.js" + +export const HeroContents = () => ( +
+
+ + +
+ +
+) diff --git a/ark/docs/src/components/HomeDemo.tsx b/ark/docs/src/components/HomeDemo.tsx new file mode 100644 index 0000000000..cc7d79e382 --- /dev/null +++ b/ark/docs/src/components/HomeDemo.tsx @@ -0,0 +1,107 @@ +import Editor, { useMonaco } from "@monaco-editor/react" +import type * as Monaco from "monaco-editor" +import { wireTmGrammars } from "monaco-editor-textmate" +import { Registry } from "monaco-textmate" +import { loadWASM } from "onigasm" +import onigasm from "onigasm/lib/onigasm.wasm?url" +import React, { useState } from "react" +import "../styles.css" +import arkdarkColors from "./arkdark.json" +// import syntax from "./syntax.ts?raw" +import arktypeTextmate from "./tsWithArkType.tmLanguage.json" + +interface IVSCodeTheme { + colors: { + [name: string]: string + } + tokenColors: ITokenColor[] +} +interface ITokenColor { + scope: string | string[] + settings: { + foreground?: string + background?: string + fontStyle?: string + } +} + +const translateVSCodeTheme = ( + theme: IVSCodeTheme +): Monaco.editor.IStandaloneThemeData => { + theme.colors["editor.background"] = "#f5cf8f0a" + return { + base: "vs-dark", + inherit: false, + 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 { + token: c.scope, + background: c.settings.background, + foreground: c.settings.foreground, + fontStyle: c.settings.fontStyle + } as Monaco.editor.ITokenThemeRule + }) + } +} + +const theme = translateVSCodeTheme(arkdarkColors) + +const setupMonaco = async (monaco: typeof Monaco) => { + await loadWASM(onigasm) + monaco.editor.defineTheme("arkdark", theme) + await wireTmGrammars( + monaco, + new Registry({ + getGrammarDefinition: async () => ({ + format: "json", + content: arktypeTextmate + }) + }), + new Map().set("typescript", "source.ts") + ) +} + +export const HomeDemo = () => { + const [loaded, setLoaded] = useState(false) + const monaco = useMonaco() + if (monaco && !loaded) setupMonaco(monaco).then(() => setLoaded(true)) + return loaded ? + { + // TODO: ? + monaco + const editorElement = editor.getDomNode() + if (editorElement) { + editorElement.style.borderRadius = "16px" + editorElement.style.boxShadow = + "0 10px 15px 0 rgba(0, 0, 0, 0.3), 0 15px 30px 0 rgba(0, 0, 0, 0.22)" + editorElement.style.transition = + "all 0.3s cubic-bezier(.25,.8,.25,1)" + editorElement.style.backdropFilter = "blur(16px)" + const guard = editorElement?.querySelector( + ".overflow-guard" + ) as HTMLElement | null + guard!.style.borderRadius = "16px" + } + }} + /> + : "Loading..." +} diff --git a/ark/docs/src/components/PlatformCloud.tsx b/ark/docs/src/components/PlatformCloud.tsx new file mode 100644 index 0000000000..adad257ddd --- /dev/null +++ b/ark/docs/src/components/PlatformCloud.tsx @@ -0,0 +1,94 @@ +import { motion } from "framer-motion" +import React from "react" +import Bun from "../assets/bun.svg" +import Chromium from "../assets/chromium.svg" +import Deno from "../assets/deno.svg" +import Intellij from "../assets/intellij.svg" +import Js from "../assets/js.svg" +import Neovim from "../assets/neovim.svg" +import Node from "../assets/node.svg" +import Ts from "../assets/ts.svg" +import Vscode from "../assets/vscode.svg" + +export type SvgLogoProps = { + name: PlatformName +} + +type PlatformName = keyof typeof platforms + +const platforms = { + js: Js, + chromium: Chromium, + node: Node, + deno: Deno, + ts: Ts, + neovim: Neovim, + vscode: Vscode, + intellij: Intellij, + bun: Bun +} + +const SvgLogo = ({ name }: SvgLogoProps) => ( + +) + +export type PlatformCloudProps = { + main: PlatformName + right: PlatformName + top: PlatformName + left: PlatformName +} + +export const PlatformCloud = ({ + main, + right, + top, + left +}: PlatformCloudProps) => ( +
+
+ +
+
+ +
+
+ +
+ + + +
+) diff --git a/ark/docs/src/components/arkdark.json b/ark/docs/src/components/arkdark.json new file mode 100644 index 0000000000..cea850b711 --- /dev/null +++ b/ark/docs/src/components/arkdark.json @@ -0,0 +1,242 @@ +{ + "$schema": "vscode://schemas/color-theme", + "name": "ArkDark", + "tokenColors": [ + { + "name": "functions", + "scope": [ + "entity.name.function", + "meta.function-call", + "support.function", + "meta.function.decorator punctuation", + "meta.function.decorator support.type" + ], + "settings": { + "foreground": "#80cff8" + } + }, + { + "name": "types", + "scope": ["entity.name.type"], + "settings": { + "foreground": "#40decc" + } + }, + { + "name": "keywords and operators", + "scope": [ + "keyword", + "storage", + "punctuation", + "constant.character.escape" + ], + "settings": { + "foreground": "#eb9f2e" + } + }, + { + "name": "italics", + "scope": ["keyword", "storage", "keyword.operator.expression"], + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "unitalicized", + "scope": ["keyword.operator", "storage.type.function.arrow.ts"], + "settings": { + "fontStyle": "" + } + }, + { + "name": "properties and strings", + "scope": [ + "variable.other.property", + "variable.other.constant.property", + "string" + ], + "settings": { + "foreground": "#f5cf8f" + } + }, + { + "name": "this, primitives, built-in types, quotes", + "scope": [ + "variable.language", + "constant", + "support.type", + "entity.name.tag", + "punctuation.definition.string.begin", + "punctuation.definition.string.end", + "punctuation.definition.string.template.begin", + "punctuation.definition.string.template.end" + ], + "settings": { + "foreground": "#408fde" + } + }, + { + "name": "errors", + "scope": ["invalid", "support.type.exception"], + "settings": { + "foreground": "#9558f8" + } + }, + { + "name": "comment", + "scope": ["comment", "punctuation.definition.comment"], + "settings": { + "foreground": "#888899" + } + }, + { + "name": "variable", + "scope": ["variable", "meta.function-call.arguments"], + "settings": { + "foreground": "#eeeeee" + } + } + ], + "colors": { + "editor.foreground": "#eeeeee", + "errorForeground": "#9558f8", + "editorError.foreground": "#9558f8", + "editorWarning.foreground": "#2faf64", + "minimap.errorHighlight": "#9558f8", + "list.errorForeground": "#9558f8", + "editorOverviewRuler.errorForeground": "#9558f8", + "editorBracketHighlight.foreground1": "#f5cf8f", + "editorBracketHighlight.foreground2": "#eb9f2e", + "editorBracketHighlight.foreground3": "#f5cf8f", + "editorBracketHighlight.unexpectedBracket.foreground": "#eb9f2e", + "editorCursor.foreground": "#408fde", + "terminalCursor.foreground": "#408fde", + "editorCodeLens.foreground": "#00ccff60", + "editorInlayHint.foreground": "#00ccff60", + "errorLens.errorBackground": "#9558f818", + "errorLens.errorForeground": "#9558f8a0", + "errorLens.warningBackground": "#00b7e40c", + "errorLens.warningForeground": "#00ccff60", + "errorLens.infoBackground": "#00b7e40c", + "errorLens.infoForeground": "#00ccff60", + "errorLens.hintBackground": "#17a2a20c", + "errorLens.hintForeground": "#2faf6460", + "activityBar.activeBorder": "#0078D4", + "activityBar.background": "#181818", + "activityBar.border": "#2B2B2B", + "activityBar.foreground": "#D7D7D7", + "activityBar.inactiveForeground": "#868686", + "activityBarBadge.background": "#0078D4", + "activityBarBadge.foreground": "#FFFFFF", + "badge.background": "#616161", + "badge.foreground": "#F8F8F8", + "button.background": "#0078D4", + "button.border": "#FFFFFF12", + "button.foreground": "#FFFFFF", + "button.hoverBackground": "#026EC1", + "button.secondaryBackground": "#313131", + "button.secondaryForeground": "#CCCCCC", + "button.secondaryHoverBackground": "#3C3C3C", + "chat.slashCommandBackground": "#34414B", + "chat.slashCommandForeground": "#40A6FF", + "checkbox.background": "#313131", + "checkbox.border": "#3C3C3C", + "debugToolBar.background": "#181818", + "descriptionForeground": "#9D9D9D", + "dropdown.background": "#313131", + "dropdown.border": "#3C3C3C", + "dropdown.foreground": "#CCCCCC", + "dropdown.listBackground": "#1F1F1F", + "editor.background": "#1F1F1F", + "editor.findMatchBackground": "#9E6A03", + "editorGroup.border": "#FFFFFF17", + "editorGroupHeader.tabsBackground": "#181818", + "editorGroupHeader.tabsBorder": "#2B2B2B", + "editorGutter.addedBackground": "#2EA043", + "editorGutter.deletedBackground": "#F85149", + "editorGutter.modifiedBackground": "#0078D4", + "editorLineNumber.activeForeground": "#CCCCCC", + "editorLineNumber.foreground": "#6E7681", + "editorOverviewRuler.border": "#010409", + "editorWidget.background": "#202020", + "focusBorder": "#0078D4", + "foreground": "#CCCCCC", + "icon.foreground": "#CCCCCC", + "input.background": "#313131", + "input.border": "#3C3C3C", + "input.foreground": "#CCCCCC", + "input.placeholderForeground": "#9D9D9D", + "inputOption.activeBackground": "#2489DB82", + "inputOption.activeBorder": "#2488DB", + "keybindingLabel.foreground": "#CCCCCC", + "menu.background": "#1F1F1F", + "notificationCenterHeader.background": "#1F1F1F", + "notificationCenterHeader.foreground": "#CCCCCC", + "notifications.background": "#1F1F1F", + "notifications.border": "#2B2B2B", + "notifications.foreground": "#CCCCCC", + "panel.background": "#181818", + "panel.border": "#2B2B2B", + "panelInput.border": "#2B2B2B", + "panelTitle.activeBorder": "#0078D4", + "panelTitle.activeForeground": "#CCCCCC", + "panelTitle.inactiveForeground": "#9D9D9D", + "peekViewEditor.background": "#1F1F1F", + "peekViewEditor.matchHighlightBackground": "#BB800966", + "peekViewResult.background": "#1F1F1F", + "peekViewResult.matchHighlightBackground": "#BB800966", + "pickerGroup.border": "#3C3C3C", + "progressBar.background": "#0078D4", + "quickInput.background": "#222222", + "quickInput.foreground": "#CCCCCC", + "settings.dropdownBackground": "#313131", + "settings.dropdownBorder": "#3C3C3C", + "settings.headerForeground": "#FFFFFF", + "settings.modifiedItemIndicator": "#BB800966", + "sideBar.background": "#181818", + "sideBar.border": "#2B2B2B", + "sideBar.foreground": "#CCCCCC", + "sideBarSectionHeader.background": "#181818", + "sideBarSectionHeader.border": "#2B2B2B", + "sideBarSectionHeader.foreground": "#CCCCCC", + "sideBarTitle.foreground": "#CCCCCC", + "statusBar.background": "#181818", + "statusBar.border": "#2B2B2B", + "statusBar.debuggingBackground": "#0078D4", + "statusBar.debuggingForeground": "#FFFFFF", + "statusBar.focusBorder": "#0078D4", + "statusBar.foreground": "#CCCCCC", + "statusBar.noFolderBackground": "#1F1F1F", + "statusBarItem.focusBorder": "#0078D4", + "statusBarItem.prominentBackground": "#6E768166", + "statusBarItem.remoteBackground": "#0078D4", + "statusBarItem.remoteForeground": "#FFFFFF", + "tab.activeBackground": "#1F1F1F", + "tab.activeBorder": "#1F1F1F", + "tab.activeBorderTop": "#0078D4", + "tab.activeForeground": "#FFFFFF", + "tab.border": "#2B2B2B", + "tab.hoverBackground": "#1F1F1F", + "tab.inactiveBackground": "#181818", + "tab.inactiveForeground": "#9D9D9D", + "tab.unfocusedActiveBorder": "#1F1F1F", + "tab.unfocusedActiveBorderTop": "#2B2B2B", + "tab.unfocusedHoverBackground": "#1F1F1F", + "terminal.foreground": "#CCCCCC", + "terminal.tab.activeBorder": "#0078D4", + "textBlockQuote.background": "#2B2B2B", + "textBlockQuote.border": "#616161", + "textCodeBlock.background": "#2B2B2B", + "textLink.activeForeground": "#40A6FF", + "textLink.foreground": "#40A6FF", + "textSeparator.foreground": "#21262D", + "titleBar.activeBackground": "#181818", + "titleBar.activeForeground": "#CCCCCC", + "titleBar.border": "#2B2B2B", + "titleBar.inactiveBackground": "#1F1F1F", + "titleBar.inactiveForeground": "#9D9D9D", + "welcomePage.tileBackground": "#2B2B2B", + "welcomePage.progress.foreground": "#0078D4", + "widget.border": "#313131" + } +} diff --git a/ark/docs/src/components/autoplayDemo.tsx b/ark/docs/src/components/autoplayDemo.tsx new file mode 100644 index 0000000000..b01d904b4b --- /dev/null +++ b/ark/docs/src/components/autoplayDemo.tsx @@ -0,0 +1,21 @@ +import React from "react" + +export type AutoplayDemoProps = React.DetailedHTMLProps< + React.VideoHTMLAttributes, + HTMLVideoElement +> & { src: string } + +export const AutoplayDemo = (props: AutoplayDemoProps) => ( +