From 195e28175617275ce4cf103826d12481cbab7aac Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sun, 27 Sep 2026 14:55:10 -0400 Subject: [PATCH 1/5] build: move to Yarn 4, tsdown, Oxlint, Oxfmt, and Vitest with a bounded CI and release pipeline Package builds as a CommonJS tsserver entry at 1.0.1's path plus a dual CommonJS and ESM ./api. CI builds and packs once, then runs the e2e matrix, the Node floor smoke test, and the scaling guardrail; releases run the full verify before Changesets can publish. Every test and measurement tool runs under a wall-clock deadline and a heap cap. Co-authored-by: usercao --- .changeset/README.md | 8 + .changeset/config.json | 23 + .editorconfig | 11 +- .eslintignore | 3 - .eslintrc.js | 17 - .gitattributes | 8 + .github/.node-version | 1 + .github/actions/setup/action.yml | 35 + .github/dependabot.yml | 36 + .github/needs_more_info.yml | 6 - .github/workflows/ci.yml | 243 +- .github/workflows/pullfrog.yml | 139 +- .github/workflows/release.yml | 74 + .gitignore | 9 +- .npmignore | 11 - .oxfmtrc.json | 14 + .oxlintrc.json | 37 + .prettierrc.json | 4 - .vscode/launch.json | 14 - .vscode/settings.json | 27 + .vscode/tasks.json | 22 - .yarnrc.yml | 5 + CHANGELOG.md | 102 +- package-lock.json | 4912 ------------------------- package.json | 134 +- scripts/bounded-worker.ts | 271 ++ scripts/changelog.cjs | 237 ++ scripts/changelog.d.cts | 80 + scripts/changeset-credits.ts | 150 + scripts/check-pack-contents.ts | 204 + scripts/compare-release-cases.ts | 267 ++ scripts/compare-release.ts | 773 ++++ scripts/kill-group.ts | 30 + scripts/test-package-api-types.ts | 230 ++ scripts/tsconfig.json | 14 + scripts/with-deadline.ts | 116 + test/e2e/tsserver-fixture/timeouts.ts | 26 + tsconfig.json | 22 +- tsdown.config.ts | 52 + vitest.config.ts | 61 + yarn.lock | 2275 ++++++++++++ 41 files changed, 5480 insertions(+), 5223 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json delete mode 100644 .eslintignore delete mode 100644 .eslintrc.js create mode 100644 .gitattributes create mode 100644 .github/.node-version create mode 100644 .github/actions/setup/action.yml create mode 100644 .github/dependabot.yml delete mode 100644 .github/needs_more_info.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .npmignore create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json delete mode 100644 .prettierrc.json delete mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json delete mode 100644 .vscode/tasks.json create mode 100644 .yarnrc.yml delete mode 100644 package-lock.json create mode 100644 scripts/bounded-worker.ts create mode 100644 scripts/changelog.cjs create mode 100644 scripts/changelog.d.cts create mode 100644 scripts/changeset-credits.ts create mode 100644 scripts/check-pack-contents.ts create mode 100644 scripts/compare-release-cases.ts create mode 100644 scripts/compare-release.ts create mode 100644 scripts/kill-group.ts create mode 100644 scripts/test-package-api-types.ts create mode 100644 scripts/tsconfig.json create mode 100644 scripts/with-deadline.ts create mode 100644 test/e2e/tsserver-fixture/timeouts.ts create mode 100644 tsdown.config.ts create mode 100644 vitest.config.ts create mode 100644 yarn.lock diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..9acf46a --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "access": "public", + "baseBranch": "main", + "changelog": [ + "../scripts/changelog.cjs", + { + "maintainers": [ + "570070+quantizor@users.noreply.github.com", + "Evan Jacobs", + "ej@quantizor.dev", + "probablyup@gmail.com", + "quantizor" + ] + } + ], + "commit": false, + "fixed": [], + "format": "oxfmt", + "ignore": [], + "linked": [], + "updateInternalDependencies": "patch" +} diff --git a/.editorconfig b/.editorconfig index f9fb281..c540562 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,13 @@ root = true [*] +charset = utf-8 +end_of_line = lf indent_style = space -indent_size = 4 +indent_size = 2 +insert_final_newline = true +max_line_length = 100 trim_trailing_whitespace = true -[{npm-shrinkwrap.json,package.json}] -indent_style = space -indent_size = 2 +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 37674ae..0000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -lib -e2e -.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 1c699fc..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - env: { - es6: true, - node: true, - }, - extends: ['plugin:@typescript-eslint/recommended'], - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - }, - plugins: ['@typescript-eslint'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - }, -}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4ae601c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# LF in every checkout, whatever core.autocrlf says: the formatter check expects LF, and e2e +# fixtures such as test/e2e/closed-file-project-fixture/unclosed.ts assert exact line and offset +# positions against their bytes. +* text=auto eol=lf + +*.gif binary +*.jpg binary +*.png binary diff --git a/.github/.node-version b/.github/.node-version new file mode 100644 index 0000000..df6ae33 --- /dev/null +++ b/.github/.node-version @@ -0,0 +1 @@ +24.21.0 diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..4cd37c0 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,35 @@ +name: Set up Node.js and dependencies +description: Installs Node.js, enables Corepack, restores the Yarn cache, and installs the locked dependencies. Runs after actions/checkout, which has to fetch this file first. + +inputs: + node-version: + description: Node.js version to install. Empty reads .github/.node-version, the development version. + required: false + default: '' + +runs: + using: composite + steps: + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ inputs.node-version }} + node-version-file: ${{ inputs.node-version == '' && '.github/.node-version' || '' }} + package-manager-cache: false + - name: Enable Corepack + shell: bash + run: corepack enable + - name: Find Yarn cache + id: yarn-cache-dir + shell: bash + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + - name: Cache Yarn packages + uses: actions/cache@v6 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', 'package.json') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Install dependencies + shell: bash + run: yarn install --immutable diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2303338 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + # .github/workflows/pullfrog.yml is maintained by the Pullfrog installer, which asks for no + # edits, and pullfrog/pullfrog appears nowhere else. Its actions/checkout pin cannot be + # excluded the same way without also ignoring the checkout the other workflows use. + ignore: + - dependency-name: pullfrog/pullfrog + # Covers the yarn.lock at the root and, through the Yarn workspace it declares, test/e2e's + # own package.json (docs/maintenance.md, project layout). + # No cooldown key: Dependabot's default cooldown for version updates (3 days) already exceeds + # .yarnrc.yml's npmMinimalAgeGate (1440 minutes, one day), so a version Yarn would still hold + # back never gets a Dependabot pull request. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + minor-and-patch: + patterns: + - '*' + update-types: + - minor + - patch + # TypeScript versions move only as a deliberate, reviewed change. The plain `typescript` + # dependency (package.json and test/e2e/package.json) compiles the package and runs the + # default e2e pass, so every update to it is ignored here. The e2e matrix's + # `npm:typescript@` aliases in test/e2e/package.json need no entry: Dependabot's + # npm and Yarn parser skips `npm:` alias requirements, so it never proposes updates for them. + ignore: + - dependency-name: typescript diff --git a/.github/needs_more_info.yml b/.github/needs_more_info.yml deleted file mode 100644 index b7fd5b0..0000000 --- a/.github/needs_more_info.yml +++ /dev/null @@ -1,6 +0,0 @@ -{ - daysUntilClose: 7, - needsMoreInfoLabel: 'needs more info', - perform: true, - closeComment: "This issue has been closed automatically because it needs more information and has not had recent activity." -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80cac61..cca19a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,66 +1,183 @@ -on: - push: - branches: - - main - pull_request: - branches: - - main name: Continuous Integration +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + shell: bash + jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - run: npm install - - name: Linting - run: npm run lint - compile: - name: Compile - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - run: npm install - - name: Compiling - run: npm run compile - unit: - name: Unit Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - run: npm install - - name: Compiling - run: npm run compile - - run: npm run unit - e2e: - name: E2E Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - run: npm install - - run: npm install - working-directory: ./e2e - - name: Compiling - run: npm run compile - - run: npm run e2e + lint: + name: Lint + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Formatting + run: yarn format:check + - name: Linting + run: yarn lint + compile: + name: Compile and Typecheck + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Type checking + run: yarn typecheck + # The only build in this workflow: prepack compiles lib/ before packing. + - name: Compile and pack + run: yarn pack:artifact + - name: Package API types + run: yarn test:package-api:consumers + - name: Package contents + run: yarn test:pack + - name: Upload packed package + uses: actions/upload-artifact@v7 + with: + name: package-tarball + path: package-artifact/*.tgz + if-no-files-found: error + retention-days: 1 + - name: Upload build output + uses: actions/upload-artifact@v7 + with: + name: lib + path: lib + if-no-files-found: error + retention-days: 1 + unit: + name: Unit Tests + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - run: yarn test:unit + - name: Scaling guardrail + run: yarn test:scaling + e2e: + name: E2E (${{ matrix.name }}) + runs-on: ubuntu-24.04 + needs: compile + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - name: Node 22.12 / TypeScript 5.0.4 (minimum supported) + node-version: 22.12.0 + typescript-package: typescript-minimum + test-package-runtime: true + - name: Node 22.12 / TypeScript 5.9.3 + node-version: 22.12.0 + typescript-package: typescript-5.9 + - name: Node 22.12 / latest supported TypeScript + node-version: 22.12.0 + typescript-package: typescript-current + # No node-version: the setup action reads .github/.node-version. + - name: development Node / latest supported TypeScript + typescript-package: typescript-current + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup + with: + node-version: ${{ matrix.node-version }} + - name: Download build output + uses: actions/download-artifact@v8 + with: + name: lib + path: lib + - name: Download packed package + if: matrix.test-package-runtime + uses: actions/download-artifact@v8 + with: + name: package-tarball + path: package-artifact + - name: Test packed package runtime + if: matrix.test-package-runtime + run: yarn test:package-api package-artifact/*.tgz + - name: Run tsserver scenarios + run: yarn test:e2e:run + env: + TSSERVER_TYPESCRIPT_PACKAGE: ${{ matrix.typescript-package }} + tsserver-smoke-floor: + # The vitest-based E2E suite above cannot run on this package's own Node floor, 14.21.3 + # (docs/tsserver-host.md): vitest declares a newer engines.node than that. The floor is + # proven instead with a plain-CommonJS script driving a real tsserver over stdio + # (test/package-api/tsserver-smoke.cjs). + name: tsserver smoke (Node 14.21.3 / TypeScript 5.0.4, package floor) + runs-on: ubuntu-24.04 + needs: compile + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Download packed package + uses: actions/download-artifact@v8 + with: + name: package-tarball + path: package-artifact + - name: Install Node floor runtime + uses: actions/setup-node@v7 + with: + node-version: 14.21.3 + package-manager-cache: false + - name: Real tsserver smoke test on the Node floor + run: node test/package-api/tsserver-smoke.cjs package-artifact/*.tgz node_modules/typescript-minimum + tsserver-smoke-legacy-typescript: + # TypeScript below 5.0 (isSupportedTypeScriptVersion, src/tsserver/tsserver-plugin.ts) never + # activates the plugin, for the reason recorded in docs/tsserver-host.md ("TypeScript + # versions"). This proves the plugin logs the unsupported-version message and leaves tsserver + # answering normally on one such version, on the same modern Node the rest of the e2e matrix + # uses, isolating the TypeScript-version axis from the Node-version one. + name: tsserver smoke (TypeScript 4.9.5, graceful non-activation below the 5.0 floor) + runs-on: ubuntu-24.04 + needs: compile + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Download packed package + uses: actions/download-artifact@v8 + with: + name: package-tarball + path: package-artifact + - name: Real tsserver smoke test on TypeScript 4.9.5 + run: node test/package-api/tsserver-smoke.cjs package-artifact/*.tgz node_modules/typescript-legacy + env: + TSSERVER_SMOKE_MODE: inactive diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 5f1974d..dda1461 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -2,79 +2,78 @@ name: Pullfrog run-name: ${{ inputs.name || github.workflow }} on: - workflow_dispatch: - inputs: - prompt: - type: string - description: Agent prompt - name: - type: string - description: Run name + workflow_dispatch: + inputs: + prompt: + type: string + description: Agent prompt + name: + type: string + description: Run name permissions: - contents: read + contents: read jobs: - pullfrog: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - name: Run agent - uses: pullfrog/pullfrog@v0 - with: - prompt: ${{ inputs.prompt }} - env: - # add at least one provider API key - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: - ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - XAI_API_KEY: ${{ secrets.XAI_API_KEY }} - DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} - MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} - KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} - META_MODEL_API_KEY: ${{ secrets.META_MODEL_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - - # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) - # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} - # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - # AWS_REGION: us-east-1 - # BEDROCK_MODEL_ID: - - # for Google Vertex AI (https://docs.pullfrog.com/vertex) - # VERTEX_SERVICE_ACCOUNT_JSON: >- - # ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} - # GOOGLE_CLOUD_PROJECT: my-project - # VERTEX_LOCATION: global - # VERTEX_MODEL_ID: + pullfrog: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/pullfrog@v0 + with: + prompt: ${{ inputs.prompt }} + env: + # add at least one provider API key + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + META_MODEL_API_KEY: ${{ secrets.META_MODEL_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - # for Azure OpenAI (https://docs.pullfrog.com/azure) - # AZURE_RESOURCE_NAME is the in https://.openai.azure.com - # AZURE_DEPLOYMENT is your deployment's name, not the model it serves - # AZURE_RESOURCE_NAME: my-resource - # AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }} - # AZURE_DEPLOYMENT: - # both limits are required — set them to the real limits of that model - # AZURE_CONTEXT: "400000" - # AZURE_MAX_OUTPUT: "128000" + # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) + # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} + # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # AWS_REGION: us-east-1 + # BEDROCK_MODEL_ID: - # for any OpenAI-compatible endpoint — LiteLLM, Cloudflare AI Gateway, - # self-hosted vLLM (https://docs.pullfrog.com/openai-compatible) - # OPENAI_COMPATIBLE_BASE_URL: https://litellm.example.com/v1 - # OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }} - # OPENAI_COMPATIBLE_MODEL: - # both limits are required — set them to the real limits of that model - # OPENAI_COMPATIBLE_CONTEXT: "128000" - # OPENAI_COMPATIBLE_MAX_OUTPUT: "16384" + # for Google Vertex AI (https://docs.pullfrog.com/vertex) + # VERTEX_SERVICE_ACCOUNT_JSON: >- + # ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} + # GOOGLE_CLOUD_PROJECT: my-project + # VERTEX_LOCATION: global + # VERTEX_MODEL_ID: + + # for Azure OpenAI (https://docs.pullfrog.com/azure) + # AZURE_RESOURCE_NAME is the in https://.openai.azure.com + # AZURE_DEPLOYMENT is your deployment's name, not the model it serves + # AZURE_RESOURCE_NAME: my-resource + # AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }} + # AZURE_DEPLOYMENT: + # both limits are required — set them to the real limits of that model + # AZURE_CONTEXT: "400000" + # AZURE_MAX_OUTPUT: "128000" + + # for any OpenAI-compatible endpoint — LiteLLM, Cloudflare AI Gateway, + # self-hosted vLLM (https://docs.pullfrog.com/openai-compatible) + # OPENAI_COMPATIBLE_BASE_URL: https://litellm.example.com/v1 + # OPENAI_COMPATIBLE_API_KEY: ${{ secrets.OPENAI_COMPATIBLE_API_KEY }} + # OPENAI_COMPATIBLE_MODEL: + # both limits are required — set them to the real limits of that model + # OPENAI_COMPATIBLE_CONTEXT: "128000" + # OPENAI_COMPATIBLE_MAX_OUTPUT: "16384" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8d82907 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # Full history: scripts/changelog.cjs asks git which commits touched each changeset file, + # and a shallow checkout's tip commit has no parent, which reads as having added every + # file in the tree and credits whoever pushed last instead of whoever wrote the change. + fetch-depth: 0 + # Safe with changesets/action's default push mode (push-with-git-cli: false, unset here): + # it pushes the version branch and any release tags through its own Octokit client, + # authenticated by the github-token input, not this checkout's persisted git credential. + persist-credentials: false + + # Sets up Node.js without a registry-url: nothing in this job runs npm. @changesets/cli + # publishes through Yarn (package.json packageManager), and Yarn never reads the .npmrc + # that registry-url would write; it takes the publish registry from package.json + # publishConfig. + - uses: ./.github/actions/setup + + # The same gate as a local `yarn verify`: formatting, lint, typecheck, unit, scaling, the + # packed artifact, e2e on the default TypeScript, and the package API and contents checks. + # A regression any of them catches never reaches the publish step below. + - name: Verify + run: yarn verify + + - name: Create Release Pull Request or Publish + id: changesets + # Pinned to a commit, not a tag: this action can push commits and publish the package. + # v2.1.2, 2026-09-07 (github.com/changesets/action, "development branch ... compatible + # with Changesets v3"). The v1 line only supports Changesets v2 and uses different + # (camelCase) input names; this repository's @changesets/cli is 3.x. + uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + with: + create-github-releases: true + publish-script: yarn changeset-publish + version-script: yarn changeset-version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # No NPM_TOKEN: publishing relies on npm trusted publishing (OIDC), configured on + # npmjs.com for this package against this repository and this workflow file (see + # docs/maintenance.md, "Releases"). `yarn npm publish` (invoked by `changeset-publish` + # through @changesets/cli 3.x's own package-manager detection) exchanges the id-token + # permission above for a short-lived npm token on its own whenever it runs under + # GitHub Actions. Yarn 4.10.0 added that exchange (yarnpkg/berry#6898) and 4.10.3 + # fixed it for scoped packages such as this one (#6911), so 4.10.3 is the floor: any + # package.json packageManager pin at or above it keeps OIDC publishing working. + # Yarn's npmPublishProvenance setting, read from its YARN_-prefixed environment form: + # `yarn npm publish` signs a provenance statement linking the published tarball to this + # workflow run, through the same id-token permission. Yarn builds that statement before + # it contacts the registry and has no fallback, so publishing requires the id-token: + # write permission above and a runner that reaches Sigstore's Fulcio + # (https://fulcio.sigstore.dev) and Rekor (https://rekor.sigstore.dev). When either is + # down, the publish fails with nothing published; re-run this job once Sigstore recovers + # (docs/maintenance.md, "Releases"). + YARN_NPM_PUBLISH_PROVENANCE: 'true' diff --git a/.gitignore b/.gitignore index 1354d42..aab4b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ node_modules/ *.swp *.swo .DS_Store -*.log log.txt +.yarn/* + +lib +package-artifact/ -test-workspace -lib \ No newline at end of file +# Local documentation that should not be committed. +docs/local/ diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 2dc5299..0000000 --- a/.npmignore +++ /dev/null @@ -1,11 +0,0 @@ -node_modules/ -*.log -*.swp -*.swo -.DS_Store -*.map -log.txt - -test-workspace -e2e -*.test.js \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..5b8d8e8 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,14 @@ +{ + // test/e2e/closed-file-project-fixture/unclosed.ts must end right after its closing backtick, + // with no trailing newline: that exact byte shape is what makes the plugin compute a template + // end past the last real character, the crash test/e2e/scenarios/errors.test.ts guards against + // (docs/architecture.md, "Mapping back to the template"). insertFinalNewline (the project-wide + // .editorconfig default) would silently reintroduce it on every format run. + "ignorePatterns": ["test/e2e/closed-file-project-fixture/unclosed.ts"], + "semi": false, + "singleQuote": true, + "sortImports": true, + "sortPackageJson": { + "sortScripts": true + } +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..09a69d4 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,37 @@ +{ + "plugins": ["typescript", "unicorn", "oxc", "import"], + "overrides": [ + { + "files": ["src/**/*.ts"], + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "Buffer", + "message": "src/ is reachable from ./api, which a bundler could ship to a browser; use a pure-JavaScript alternative (see flattenCacheKey, src/features/diagnostics.ts)." + }, + { + "name": "process", + "message": "src/ is reachable from ./api, which a bundler could ship to a browser; process does not exist there." + }, + { + "name": "require", + "message": "src/ is reachable from ./api, which a bundler could ship to a browser; require does not exist there." + }, + { + "name": "module", + "message": "src/ is reachable from ./api, which a bundler could ship to a browser; module does not exist there." + }, + { + "name": "__dirname", + "message": "src/ is reachable from ./api, which a bundler could ship to a browser; __dirname does not exist there." + }, + { + "name": "__filename", + "message": "src/ is reachable from ./api, which a bundler could ship to a browser; __filename does not exist there." + } + ] + } + } + ] +} diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index d042ec4..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "printWidth": 120 -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index d226b0c..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "attach", - "name": "Debug Styled Plugin", - "skipFiles": ["/**"], - "outFiles": ["${workspaceFolder}/lib/**/*.js"], - "port": 9229, - "preLaunchTask": "npm: watch:compile" - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6b72ba7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + // Use the project-local Oxfmt through the official Oxc extension + "prettier.enable": false, + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.detectIndentation": false, + "editor.formatOnSave": false, + "editor.insertSpaces": true, + "editor.rulers": [100], + "editor.tabSize": 2, + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + + // Apply safe Oxlint fixes after formatting + "editor.codeActionsOnSave": { + "source.format.oxc": "always", + "source.fixAll.oxc": "always", + "source.organizeImports": "never" + }, + + // Markdown uses trailing spaces for explicit line breaks + "[markdown]": { + "files.trimTrailingWhitespace": false + }, + "oxc.disableNestedConfig": true, + "oxc.fmt.disableNestedConfig": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a1e2942..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "watch:compile", - "problemMatcher": [ - { - "base": "$tsc-watch", - "background": { - "activeOnStart": true, - "beginsPattern": "Starting compilation in watch mode", - "endsPattern": "Watching\\sfor\\sfile\\schanges\\." - } - } - ], - "isBackground": true - } - ] -} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..1107e19 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,5 @@ +nodeLinker: node-modules + +npmMinimalAgeGate: 1440 + +enableScripts: false diff --git a/CHANGELOG.md b/CHANGELOG.md index e11414f..e85f18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,171 +2,171 @@ ## 1.0.1 - December 11, 2023 -- Add support for `@container` queries by updating the CSS language service -- Update TypeScript and other dependencies +- Add support for `@container` queries by updating the CSS language service +- Update TypeScript and other dependencies ## 1.0.0 - April 3, 2023 -- Fix upstream issue with typescript-template-language-service-decorator +- Fix upstream issue with typescript-template-language-service-decorator ## 0.20.0 - December 01, 2022 -- Forked into @styled +- Forked into @styled ## 0.18.2 - December 06, 2021 -- Include completion spans in responses. Thanks @jasonwilliams! -- Don't trigger completions when opening template tags. Thanks @jasonwilliams! +- Include completion spans in responses. Thanks @jasonwilliams! +- Don't trigger completions when opening template tags. Thanks @jasonwilliams! ## 0.18.1 - August 05, 2021 -- Update emmet. Thanks @jasonwilliams! +- Update emmet. Thanks @jasonwilliams! ## 0.18.0 - June 03, 2021 -- Enable IntelliSense in `keyframes`. Thanks @jasonwilliams! +- Enable IntelliSense in `keyframes`. Thanks @jasonwilliams! ## 0.17.0 - May 07, 2021 -- Apply in `keyframes` by default. Thanks @jasonwilliams! +- Apply in `keyframes` by default. Thanks @jasonwilliams! ## 0.16.0 - April 28, 2021 -- Pick up new language service version. Thanks @hantatsang and @jasonwilliams! +- Pick up new language service version. Thanks @hantatsang and @jasonwilliams! ## 0.15.0 - October 16, 2019 -- Pick up new language service version. Thanks @apust! +- Pick up new language service version. Thanks @apust! ## 0.14.0 - February 26, 2019 -- Pick up new language service version. -- Support for dynamically changing configuration. -- Only enable plugin for TS 3.0+ in order to support automatically enabling plugin for workspace TS versions. +- Pick up new language service version. +- Support for dynamically changing configuration. +- Only enable plugin for TS 3.0+ in order to support automatically enabling plugin for workspace TS versions. ## 0.13.0 - November 8, 2018 -- Mark color completions with the `'color'` `kindModifier`. This allows editors to render the color previews inline. -- Fix more false positive errors. +- Mark color completions with the `'color'` `kindModifier`. This allows editors to render the color previews inline. +- Fix more false positive errors. ## 0.12.0 - October 15, 2018 -- Pick up new decorator library version to fix a possible state corruption error. +- Pick up new decorator library version to fix a possible state corruption error. ## 0.11.0 - September 11, 2018 -- Fixed some false positive errors when using a placeholder in a contexual selector. Thanks @lukyth! -- Apply in `injectGlobal` or `createGlobalStyle` by default. Thanks @scf4! +- Fixed some false positive errors when using a placeholder in a contexual selector. Thanks @lukyth! +- Apply in `injectGlobal` or `createGlobalStyle` by default. Thanks @scf4! ## 0.10.0 - July 10, 2018 -- Add folding support. +- Add folding support. ## 0.9.2 - July 9, 2018 -- Remove TS as peerDep. +- Remove TS as peerDep. ## 0.9.1 - July 9, 2018 -- Allow language service to be consumed by other libraries. +- Allow language service to be consumed by other libraries. ## 0.8.1 - July 2, 2018 -- Fix some false error reports around creative uses of placeholders. +- Fix some false error reports around creative uses of placeholders. ## 0.8.0 - July 2, 2018 -- Support for emotion style typescript declarations. +- Support for emotion style typescript declarations. ## 0.7.0 - June 25, 2018 -- Picked up new CSS version. Brings improved suggestions and better documentation. +- Picked up new CSS version. Brings improved suggestions and better documentation. ## 0.6.3 - April 20, 2018 -- Fixed `width: ${1}%;` incorrectly reported as an error. +- Fixed `width: ${1}%;` incorrectly reported as an error. ## 0.6.2 - April 18, 2018 -- Fixed case where a placeholder that looked like a mixin was incorrectly reported as an error. +- Fixed case where a placeholder that looked like a mixin was incorrectly reported as an error. ## 0.6.1 - April 16, 2018 -- Fixed some cases where placeholder usage was incorrectly reported as an error. +- Fixed some cases where placeholder usage was incorrectly reported as an error. ## 0.6.0 - February 16, 2018 -- Added emmet suggestions. Thanks @ramya-rao-a! +- Added emmet suggestions. Thanks @ramya-rao-a! ## 0.5.1 - February 13, 2018 -- Small fix for suggestions inside of nested selectors. +- Small fix for suggestions inside of nested selectors. ## 0.5.0 - February 12, 2018 -- Add quick fixes for misspelled property names. +- Add quick fixes for misspelled property names. ## 0.4.1 - February 12, 2018 -- Fixed false error when placeholder is used as a selector. +- Fixed false error when placeholder is used as a selector. ## 0.4.0 - January 16, 2018 -- Fix suggestions inside of nested selectors. Thanks @aczekajski! +- Fix suggestions inside of nested selectors. Thanks @aczekajski! ## 0.3.1 - January 11, 2018 -- Cache completion entries so we don't recompute them as often. +- Cache completion entries so we don't recompute them as often. ## 0.3.0 - January 9, 2018 -- Added basic support for completion entry details +- Added basic support for completion entry details ## 0.2.2 - November 29, 2017 -- Fix auto import completions not showing up when using plugin with TS 2.6.2+ +- Fix auto import completions not showing up when using plugin with TS 2.6.2+ ## 0.2.1 - November 27, 2017 -- Fix cases where placeholder is followed by a trailing semicolon. Thanks @kingdaro! +- Fix cases where placeholder is followed by a trailing semicolon. Thanks @kingdaro! ## 0.2.0 - November 9, 2017 -- Do not take runtime dependecy on TypeScript. +- Do not take runtime dependecy on TypeScript. ## 0.1.2 — October 24, 2017 -- Fix bug that could cause errors not to be reported when on the last line of a block. +- Fix bug that could cause errors not to be reported when on the last line of a block. ## 0.1.1 — October 24, 2017 -- Compile to ES5 to support regular Visual Studio +- Compile to ES5 to support regular Visual Studio ## 0.1.0 -- Support for nested classes. Thanks @asvetliakov! -- Support for styled properties, such as `MyButton.extend...`. Thanks @asvetliakov! -- Fix a bug that could cause errors to stop being reported. +- Support for nested classes. Thanks @asvetliakov! +- Support for styled properties, such as `MyButton.extend...`. Thanks @asvetliakov! +- Fix a bug that could cause errors to stop being reported. ### 0.0.5 - September 29, 2017 -- Fix empty value error being showing when using placeholder for value in multiline template strings. +- Fix empty value error being showing when using placeholder for value in multiline template strings. ### 0.0.4 - September 29, 2017 -- Fix multiline strings with placeholders. +- Fix multiline strings with placeholders. ### 0.0.3 - September 29, 2017 -- Initial support for strings with placeholders. +- Initial support for strings with placeholders. ### 0.0.2 - September 29, 2017 -- Disable empty ruleset lint error by default -- Fix styled completions showing on character immediately before start of string -- Supprt `css` tag by default. -- Remove a bunch of files from published npm package. +- Disable empty ruleset lint error by default +- Fix styled completions showing on character immediately before start of string +- Supprt `css` tag by default. +- Remove a bunch of files from published npm package. ### 0.0.1 - September 28, 2017 -- Initial release +- Initial release diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 87decf6..0000000 --- a/package-lock.json +++ /dev/null @@ -1,4912 +0,0 @@ -{ - "name": "@styled/typescript-styled-plugin", - "version": "1.0.1", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "@styled/typescript-styled-plugin", - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "@vscode/emmet-helper": "^2.9.2", - "typescript-template-language-service-decorator": "^2.3.2", - "vscode-css-languageservice": "^6.2.11", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "^3.17.5" - }, - "devDependencies": { - "@types/chai": "^4.3.4", - "@types/mocha": "^10.0.1", - "@types/node": "^18.11.9", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "chai": "^4.3.7", - "eslint": "^8.55.0", - "eslint-plugin-prettier": "^5.0.1", - "glob": "^8.0.3", - "mocha": "^10.1.0", - "prettier": "^3.1.1", - "typescript": "^5.3.3" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@emmetio/abbreviation": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", - "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", - "dependencies": { - "@emmetio/scanner": "^1.0.4" - } - }, - "node_modules/@emmetio/css-abbreviation": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", - "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", - "dependencies": { - "@emmetio/scanner": "^1.0.4" - } - }, - "node_modules/@emmetio/scanner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", - "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==" - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz", - "integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", - "dev": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgr/utils": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", - "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "fast-glob": "^3.3.0", - "is-glob": "^4.0.3", - "open": "^9.1.0", - "picocolors": "^1.0.0", - "tslib": "^2.6.0" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, - "node_modules/@types/chai": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz", - "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/mocha": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", - "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", - "dev": true - }, - "node_modules/@types/node": { - "version": "18.11.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz", - "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", - "dev": true - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.14.0.tgz", - "integrity": "sha512-1ZJBykBCXaSHG94vMMKmiHoL0MhNHKSVlcHVYZNw+BKxufhqQVTOawNpwwI1P5nIFZ/4jLVop0mcY6mJJDFNaw==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/type-utils": "6.14.0", - "@typescript-eslint/utils": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.14.0.tgz", - "integrity": "sha512-QjToC14CKacd4Pa7JK4GeB/vHmWFJckec49FR4hmIRf97+KXole0T97xxu9IFiPxVQ1DBWrQ5wreLwAGwWAVQA==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/typescript-estree": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.14.0.tgz", - "integrity": "sha512-VT7CFWHbZipPncAZtuALr9y3EuzY1b1t1AEkIq2bTXUPKw+pHoXflGNG5L+Gv6nKul1cz1VH8fz16IThIU0tdg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.14.0.tgz", - "integrity": "sha512-x6OC9Q7HfYKqjnuNu5a7kffIYs3No30isapRBJl1iCHLitD8O0lFbRcVGiOcuyN837fqXzPZ1NS10maQzZMKqw==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "6.14.0", - "@typescript-eslint/utils": "6.14.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.14.0.tgz", - "integrity": "sha512-uty9H2K4Xs8E47z3SnXEPRNDfsis8JO27amp2GNCnzGETEW3yTqEIVg5+AI7U276oGF/tw6ZA+UesxeQ104ceA==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.14.0.tgz", - "integrity": "sha512-yPkaLwK0yH2mZKFE/bXkPAkkFgOv15GJAUzgUVonAbv0Hr4PK/N2yaA/4XQbTZQdygiDkpt5DkxPELqHguNvyw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.14.0.tgz", - "integrity": "sha512-XwRTnbvRr7Ey9a1NT6jqdKX8y/atWG+8fAIu3z73HSP8h06i3r/ClMhmaF/RGWGW1tHJEwij1uEg2GbEmPYvYg==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/typescript-estree": "6.14.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.14.0.tgz", - "integrity": "sha512-fB5cw6GRhJUz03MrROVuj5Zm/Q+XWlVdIsFj+Zb1Hvqouc8t+XP2H5y53QYU/MGtd2dPg6/vJJlhoX3xc2ehfw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.14.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vscode/emmet-helper": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.9.2.tgz", - "integrity": "sha512-MaGuyW+fa13q3aYsluKqclmh62Hgp0BpKIqS66fCxfOaBcVQ1OnMQxRRgQUYnCkxFISAQlkJ0qWWPyXjro1Qrg==", - "dependencies": { - "emmet": "^2.4.3", - "jsonc-parser": "^2.3.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.15.1", - "vscode-uri": "^2.1.2" - } - }, - "node_modules/@vscode/emmet-helper/node_modules/vscode-uri": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", - "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==" - }, - "node_modules/@vscode/l10n": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.16.tgz", - "integrity": "sha512-JT5CvrIYYCrmB+dCana8sUqJEcGB1ZDXNLMQ2+42bW995WmNoenijWMUdZfwmuQUTQcEVVIa2OecZzTYWUW9Cg==" - }, - "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/bplist-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", - "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", - "dev": true, - "dependencies": { - "big-integer": "^1.6.44" - }, - "engines": { - "node": ">= 5.10.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "node_modules/bundle-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", - "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", - "dev": true, - "dependencies": { - "run-applescript": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", - "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", - "dev": true, - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.2.tgz", - "integrity": "sha512-gT18+YW4CcW/DBNTwAmqTtkJh7f9qqScu2qFVlx7kCoeY9tlBu9cUcr7+I+Z/noG8INehS3xQgLpTtd/QUTn4w==", - "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/default-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", - "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", - "dev": true, - "dependencies": { - "bundle-name": "^3.0.0", - "default-browser-id": "^3.0.0", - "execa": "^7.1.1", - "titleize": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", - "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", - "dev": true, - "dependencies": { - "bplist-parser": "^0.2.0", - "untildify": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/emmet": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.6.tgz", - "integrity": "sha512-dJfbdY/hfeTyf/Ef7Y7ubLYzkBvPQ912wPaeVYpAxvFxkEBf/+hJu4H6vhAvFN6HlxqedlfVn2x1S44FfQ97pg==", - "dependencies": { - "@emmetio/abbreviation": "^2.3.3", - "@emmetio/css-abbreviation": "^2.1.8" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", - "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.55.0", - "@humanwhocodes/config-array": "^0.11.13", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", - "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", - "dev": true, - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.5" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/eslint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^3.0.7", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": "^14.18.0 || ^16.14.0 || >=18.0.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", - "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", - "dev": true - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", - "integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.1.tgz", - "integrity": "sha512-362NP+zlprccbEt/SkxKfRMHnNY85V74mVnpUpNyr3F35covl09Kec7/sEFLt3RA4oXmewtoaanoIf67SE5Y5g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "bin": { - "he": "bin/he" - } - }, - "node_modules/human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", - "dev": true, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-wsl/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "node_modules/jsonc-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", - "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==" - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", - "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.0" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mocha": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.1.0.tgz", - "integrity": "sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==", - "dev": true, - "dependencies": { - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.4", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.2.0", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "5.0.1", - "ms": "2.1.3", - "nanoid": "3.3.3", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "workerpool": "6.2.1", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": ">= 14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" - } - }, - "node_modules/mocha/node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mocha/node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha/node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", - "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", - "dev": true, - "dependencies": { - "default-browser": "^4.0.0", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", - "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-applescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", - "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", - "dev": true, - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/run-applescript/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/run-applescript/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/run-applescript/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/run-applescript/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/synckit": { - "version": "0.8.6", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.6.tgz", - "integrity": "sha512-laHF2savN6sMeHCjLRkheIU4wo3Zg9Ln5YOjOo7sZ5dVQW8yF5pPE5SIw1dsPhq3TRp1jisKRCdPhfs/1WMqDA==", - "dev": true, - "dependencies": { - "@pkgr/utils": "^2.4.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "node_modules/titleize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", - "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", - "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", - "dev": true, - "engines": { - "node": ">=16.13.0" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-template-language-service-decorator": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-2.3.2.tgz", - "integrity": "sha512-hN0zNkr5luPCeXTlXKxsfBPlkAzx86ZRM1vPdL7DbEqqWoeXSxplACy98NpKpLmXsdq7iePUzAXloCAoPKBV6A==" - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vscode-css-languageservice": { - "version": "6.2.11", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.2.11.tgz", - "integrity": "sha512-qn49Wa6K94LnizpVxmlYrcPf1Cb36gq1nNueW0COhi4shylXBzET5wuDbH8ZWQlJD0HM5Mmnn7WE9vQVVs+ULA==", - "dependencies": { - "@vscode/l10n": "^0.0.16", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "3.17.5", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz", - "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" - }, - "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/workerpool": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", - "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", - "dev": true - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "node_modules/y18n": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", - "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - }, - "dependencies": { - "@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true - }, - "@emmetio/abbreviation": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", - "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", - "requires": { - "@emmetio/scanner": "^1.0.4" - } - }, - "@emmetio/css-abbreviation": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", - "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", - "requires": { - "@emmetio/scanner": "^1.0.4" - } - }, - "@emmetio/scanner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", - "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==" - }, - "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.3.0" - } - }, - "@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true - }, - "@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - } - }, - "@eslint/js": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz", - "integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==", - "dev": true - }, - "@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", - "dev": true - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@pkgr/utils": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", - "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "fast-glob": "^3.3.0", - "is-glob": "^4.0.3", - "open": "^9.1.0", - "picocolors": "^1.0.0", - "tslib": "^2.6.0" - } - }, - "@types/chai": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz", - "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", - "dev": true - }, - "@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "@types/mocha": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", - "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", - "dev": true - }, - "@types/node": { - "version": "18.11.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz", - "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==", - "dev": true - }, - "@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", - "dev": true - }, - "@typescript-eslint/eslint-plugin": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.14.0.tgz", - "integrity": "sha512-1ZJBykBCXaSHG94vMMKmiHoL0MhNHKSVlcHVYZNw+BKxufhqQVTOawNpwwI1P5nIFZ/4jLVop0mcY6mJJDFNaw==", - "dev": true, - "requires": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/type-utils": "6.14.0", - "@typescript-eslint/utils": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - } - }, - "@typescript-eslint/parser": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.14.0.tgz", - "integrity": "sha512-QjToC14CKacd4Pa7JK4GeB/vHmWFJckec49FR4hmIRf97+KXole0T97xxu9IFiPxVQ1DBWrQ5wreLwAGwWAVQA==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/typescript-estree": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", - "debug": "^4.3.4" - } - }, - "@typescript-eslint/scope-manager": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.14.0.tgz", - "integrity": "sha512-VT7CFWHbZipPncAZtuALr9y3EuzY1b1t1AEkIq2bTXUPKw+pHoXflGNG5L+Gv6nKul1cz1VH8fz16IThIU0tdg==", - "dev": true, - "requires": { - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0" - } - }, - "@typescript-eslint/type-utils": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.14.0.tgz", - "integrity": "sha512-x6OC9Q7HfYKqjnuNu5a7kffIYs3No30isapRBJl1iCHLitD8O0lFbRcVGiOcuyN837fqXzPZ1NS10maQzZMKqw==", - "dev": true, - "requires": { - "@typescript-eslint/typescript-estree": "6.14.0", - "@typescript-eslint/utils": "6.14.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - } - }, - "@typescript-eslint/types": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.14.0.tgz", - "integrity": "sha512-uty9H2K4Xs8E47z3SnXEPRNDfsis8JO27amp2GNCnzGETEW3yTqEIVg5+AI7U276oGF/tw6ZA+UesxeQ104ceA==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.14.0.tgz", - "integrity": "sha512-yPkaLwK0yH2mZKFE/bXkPAkkFgOv15GJAUzgUVonAbv0Hr4PK/N2yaA/4XQbTZQdygiDkpt5DkxPELqHguNvyw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/visitor-keys": "6.14.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - } - }, - "@typescript-eslint/utils": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.14.0.tgz", - "integrity": "sha512-XwRTnbvRr7Ey9a1NT6jqdKX8y/atWG+8fAIu3z73HSP8h06i3r/ClMhmaF/RGWGW1tHJEwij1uEg2GbEmPYvYg==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.14.0", - "@typescript-eslint/types": "6.14.0", - "@typescript-eslint/typescript-estree": "6.14.0", - "semver": "^7.5.4" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.14.0.tgz", - "integrity": "sha512-fB5cw6GRhJUz03MrROVuj5Zm/Q+XWlVdIsFj+Zb1Hvqouc8t+XP2H5y53QYU/MGtd2dPg6/vJJlhoX3xc2ehfw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "6.14.0", - "eslint-visitor-keys": "^3.4.1" - } - }, - "@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "@vscode/emmet-helper": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.9.2.tgz", - "integrity": "sha512-MaGuyW+fa13q3aYsluKqclmh62Hgp0BpKIqS66fCxfOaBcVQ1OnMQxRRgQUYnCkxFISAQlkJ0qWWPyXjro1Qrg==", - "requires": { - "emmet": "^2.4.3", - "jsonc-parser": "^2.3.0", - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.15.1", - "vscode-uri": "^2.1.2" - }, - "dependencies": { - "vscode-uri": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", - "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==" - } - } - }, - "@vscode/l10n": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.16.tgz", - "integrity": "sha512-JT5CvrIYYCrmB+dCana8sUqJEcGB1ZDXNLMQ2+42bW995WmNoenijWMUdZfwmuQUTQcEVVIa2OecZzTYWUW9Cg==" - }, - "acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "bplist-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", - "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", - "dev": true, - "requires": { - "big-integer": "^1.6.44" - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "bundle-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", - "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", - "dev": true, - "requires": { - "run-applescript": "^5.0.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", - "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", - "dev": true - }, - "chai": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", - "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^4.1.2", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true - }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - }, - "dependencies": { - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true - }, - "deep-eql": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.2.tgz", - "integrity": "sha512-gT18+YW4CcW/DBNTwAmqTtkJh7f9qqScu2qFVlx7kCoeY9tlBu9cUcr7+I+Z/noG8INehS3xQgLpTtd/QUTn4w==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "default-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", - "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", - "dev": true, - "requires": { - "bundle-name": "^3.0.0", - "default-browser-id": "^3.0.0", - "execa": "^7.1.1", - "titleize": "^3.0.0" - } - }, - "default-browser-id": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", - "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", - "dev": true, - "requires": { - "bplist-parser": "^0.2.0", - "untildify": "^4.0.0" - } - }, - "define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true - }, - "diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "emmet": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.6.tgz", - "integrity": "sha512-dJfbdY/hfeTyf/Ef7Y7ubLYzkBvPQ912wPaeVYpAxvFxkEBf/+hJu4H6vhAvFN6HlxqedlfVn2x1S44FfQ97pg==", - "requires": { - "@emmetio/abbreviation": "^2.3.3", - "@emmetio/css-abbreviation": "^2.1.8" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", - "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.55.0", - "@humanwhocodes/config-array": "^0.11.13", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "dependencies": { - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - } - } - }, - "eslint-plugin-prettier": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", - "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.5" - } - }, - "eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true - }, - "espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "requires": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - } - }, - "esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^3.0.7", - "strip-final-newline": "^3.0.0" - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, - "fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", - "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", - "dev": true - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true - }, - "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true - }, - "glob": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", - "integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "minimatch": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.1.tgz", - "integrity": "sha512-362NP+zlprccbEt/SkxKfRMHnNY85V74mVnpUpNyr3F35covl09Kec7/sEFLt3RA4oXmewtoaanoIf67SE5Y5g==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } - }, - "graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", - "dev": true - }, - "ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "requires": { - "is-docker": "^3.0.0" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true - }, - "is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true - }, - "is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "requires": { - "is-docker": "^2.0.0" - }, - "dependencies": { - "is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true - } - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "jsonc-parser": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", - "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==" - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - } - }, - "loupe": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", - "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", - "dev": true, - "requires": { - "get-func-name": "^2.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "requires": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - } - }, - "mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "mocha": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.1.0.tgz", - "integrity": "sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==", - "dev": true, - "requires": { - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.4", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.2.0", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "5.0.1", - "ms": "2.1.3", - "nanoid": "3.3.3", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "workerpool": "6.2.1", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - }, - "dependencies": { - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "dependencies": { - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - } - } - }, - "minimatch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - } - } - } - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "requires": { - "path-key": "^4.0.0" - }, - "dependencies": { - "path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true - } - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "requires": { - "mimic-fn": "^4.0.0" - } - }, - "open": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", - "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", - "dev": true, - "requires": { - "default-browser": "^4.0.0", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^2.2.0" - } - }, - "optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "requires": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prettier": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", - "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", - "dev": true - }, - "prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, - "punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "requires": { - "picomatch": "^2.2.1" - } - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "run-applescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", - "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", - "dev": true, - "requires": { - "execa": "^5.0.0" - }, - "dependencies": { - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - } - }, - "human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true - } - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "synckit": { - "version": "0.8.6", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.6.tgz", - "integrity": "sha512-laHF2savN6sMeHCjLRkheIU4wo3Zg9Ln5YOjOo7sZ5dVQW8yF5pPE5SIw1dsPhq3TRp1jisKRCdPhfs/1WMqDA==", - "dev": true, - "requires": { - "@pkgr/utils": "^2.4.2", - "tslib": "^2.6.2" - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "titleize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", - "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "ts-api-utils": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", - "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", - "dev": true, - "requires": {} - }, - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true - }, - "typescript-template-language-service-decorator": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-2.3.2.tgz", - "integrity": "sha512-hN0zNkr5luPCeXTlXKxsfBPlkAzx86ZRM1vPdL7DbEqqWoeXSxplACy98NpKpLmXsdq7iePUzAXloCAoPKBV6A==" - }, - "untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "vscode-css-languageservice": { - "version": "6.2.11", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.2.11.tgz", - "integrity": "sha512-qn49Wa6K94LnizpVxmlYrcPf1Cb36gq1nNueW0COhi4shylXBzET5wuDbH8ZWQlJD0HM5Mmnn7WE9vQVVs+ULA==", - "requires": { - "@vscode/l10n": "^0.0.16", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "3.17.5", - "vscode-uri": "^3.0.8" - } - }, - "vscode-languageserver-textdocument": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz", - "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==" - }, - "vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" - }, - "vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "workerpool": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", - "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", - "dev": true - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "y18n": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", - "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", - "dev": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "dependencies": { - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", - "dev": true - }, - "yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "requires": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - } - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - } - } -} diff --git a/package.json b/package.json index 8de5c2e..bbfc62a 100644 --- a/package.json +++ b/package.json @@ -4,50 +4,120 @@ "description": "TypeScript language service plugin that adds IntelliSense for styled components", "keywords": [ "TypeScript", + "css", + "css-in-js", "styled", - "styled-components", "styled components", - "css" + "styled-components" ], - "main": "lib/index.js", - "author": "Styled Components", + "bugs": { + "url": "https://github.com/styled-components/typescript-styled-plugin/issues" + }, "license": "MIT", + "author": "Styled Components", "repository": { "type": "git", "url": "https://github.com/styled-components/typescript-styled-plugin.git" }, - "bugs": { - "url": "https://github.com/styled-components/typescript-styled-plugin/issues" + "workspaces": [ + ".", + "test/e2e" + ], + "files": [ + "lib" + ], + "type": "module", + "main": "./lib/index.js", + "types": "./lib/index.d.cts", + "typesVersions": { + "*": { + "api": [ + "lib/esm/api.d.cts" + ] + } + }, + "exports": { + ".": { + "types": "./lib/index.d.cts", + "default": "./lib/index.js" + }, + "./api": { + "import": { + "types": "./lib/esm/api.d.mts", + "default": "./lib/esm/api.mjs" + }, + "require": { + "types": "./lib/esm/api.d.cts", + "default": "./lib/esm/api.cjs" + } + }, + "./lib/index": { + "types": "./lib/index.d.cts", + "default": "./lib/index.js" + }, + "./lib/index.js": { + "types": "./lib/index.d.cts", + "default": "./lib/index.js" + }, + "./lib/api": { + "types": "./lib/api.d.ts", + "default": "./lib/api.js" + }, + "./lib/api.js": { + "types": "./lib/api.d.ts", + "default": "./lib/api.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "benchmark": "node --expose-gc --no-flush-bytecode --experimental-transform-types --no-warnings test/performance/template-language-service.bench.ts", + "changeset": "changeset", + "changeset-credits": "node --experimental-strip-types scripts/changeset-credits.ts", + "changeset-publish": "changeset publish", + "changeset-version": "changeset version", + "compare-release": "node --experimental-strip-types --no-warnings scripts/compare-release.ts", + "compile": "tsdown", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", + "lint": "oxlint . --deny-warnings", + "lint:fix": "oxlint --fix .", + "pack:artifact": "yarn pack --out package-artifact/package.tgz", + "prepack": "yarn compile", + "test": "yarn test:unit && yarn test:e2e", + "test:e2e": "yarn compile && yarn test:e2e:run", + "test:e2e:5.9": "TSSERVER_TYPESCRIPT_PACKAGE=typescript-5.9 yarn test:e2e", + "test:e2e:current": "TSSERVER_TYPESCRIPT_PACKAGE=typescript-current yarn test:e2e", + "test:e2e:minimum": "TSSERVER_TYPESCRIPT_PACKAGE=typescript-minimum yarn test:e2e", + "test:e2e:run": "node --experimental-strip-types scripts/with-deadline.ts 300000 -- vitest run test/e2e/scenarios --configLoader runner", + "test:pack": "node --experimental-strip-types scripts/check-pack-contents.ts", + "test:package-api": "node --experimental-strip-types test/package-api/runtime.ts", + "test:package-api:consumers": "node --experimental-strip-types scripts/test-package-api-types.ts", + "test:scaling": "node --expose-gc --no-flush-bytecode --experimental-transform-types --no-warnings test/performance/scaling-check.ts", + "test:unit": "node --experimental-strip-types scripts/with-deadline.ts 60000 -- vitest run test/unit --configLoader runner", + "typecheck": "tsc --noEmit -p . && tsc --noEmit -p scripts && tsc --noEmit -p test/e2e && tsc --noEmit -p test/package-api", + "verify": "yarn format:check && yarn lint && yarn typecheck && yarn test:unit && yarn test:scaling && yarn pack:artifact && yarn test:e2e:run && yarn test:package-api && yarn test:package-api:consumers && yarn test:pack", + "watch:compile": "tsdown --watch" }, "dependencies": { - "@vscode/emmet-helper": "^2.9.2", + "@vscode/emmet-helper": "2.11.0", "typescript-template-language-service-decorator": "^2.3.2", - "vscode-css-languageservice": "^6.2.11", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "^3.17.5" + "vscode-css-languageservice": "6.3.10", + "vscode-languageserver-textdocument": "1.0.15", + "vscode-languageserver-types": "3.18.4" }, - "files": [ - "lib" - ], "devDependencies": { - "@types/chai": "^4.3.4", - "@types/mocha": "^10.0.1", - "@types/node": "^18.11.9", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "chai": "^4.3.7", - "eslint": "^8.55.0", - "eslint-plugin-prettier": "^5.0.1", - "glob": "^8.0.3", - "mocha": "^10.1.0", - "prettier": "^3.1.1", - "typescript": "^5.3.3" + "@changesets/cli": "^3.0.3", + "@types/node": "^24", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", + "tinybench": "^6.2.0", + "tsdown": "^0.23.0", + "typescript": "~6.0.3", + "vite": "^8.3.1", + "vitest": "^5.0.2" }, - "scripts": { - "compile": "tsc -p .", - "watch:compile": "tsc --watch -p .", - "e2e": "mocha e2e/tests --slow 2000 --timeout 10000", - "lint": "eslint src", - "unit": "mocha ./lib/test" - } + "packageManager": "yarn@4.18.1" } diff --git a/scripts/bounded-worker.ts b/scripts/bounded-worker.ts new file mode 100644 index 0000000..d186fbd --- /dev/null +++ b/scripts/bounded-worker.ts @@ -0,0 +1,271 @@ +/** + * Runs contributor tooling that executes plugin code (the scaling check, the benchmark, the + * compare-release sensor) in a worker thread with a wall-clock deadline and a heap cap, both + * enforced from the parent thread, so a regression that makes plugin code superlinear, loop + * forever, or retain without bound fails that one task loudly instead of holding a core and + * growing into swap until someone kills the process. + * + * - Deadline: `worker.terminate()` once a task has run `deadlineMs` of wall-clock time, counted + * from `run()`, the worker's module load included for its first task. Termination stops a worker + * even inside a synchronous loop that never yields, which a timer inside that same thread could + * never interrupt. + * - Heap cap: `resourceLimits.maxOldGenerationSizeMb`. Past it, V8 ends the worker with + * ERR_WORKER_OUT_OF_MEMORY and the process keeps running. Only the old generation is capped; the + * young generation stays at V8's small default, and plugin code allocates no ArrayBuffer or + * Buffer memory, which sits outside the V8 heap. + * - The worker inherits the parent's `execArgv` (Node's default), so `--expose-gc` and + * `--experimental-transform-types` reach it; V8 flags such as `--no-flush-bytecode` apply to the + * whole process already. `process.threadCpuUsage` inside the worker reports the worker thread's + * own CPU time, and `process.memoryUsage().heapUsed` its own heap. + * - The entry is a module that calls `serveBounded` when `isMainThread` is false, often the calling + * script itself. Input, progress, and result cross the thread boundary by structured clone. A + * worker reports through progress and result, never by printing: termination can drop its + * pending console output. + * - A `BoundedWorker` runs one task at a time and stays alive between tasks, so repeated small + * tasks pay the module load once. A task stopped at its deadline or heap cap, or a crash outside + * the task handler, ends the worker: every later `run()` resolves as `crashed` at once, and the + * caller starts a new one. `runBounded` is the one-task form. + * - `close()` resolves only after the worker has exited, and `runBounded` closes its worker + * whatever the outcome, so no worker outlives its caller. `run()` never rejects: a throw inside + * the worker resolves as `crashed`. + */ +import { isMainThread, parentPort, Worker } from 'node:worker_threads' + +interface BoundedWorkerLimits { + readonly deadlineMs: number + readonly heapMb: number +} + +export type BoundedWorkerOutcome = + | { readonly elapsedMs: number; readonly kind: 'done'; readonly result: Result } + | { + readonly elapsedMs: number + readonly kind: 'deadline' | 'heap' + readonly lastProgress: Progress | undefined + } + | { + readonly elapsedMs: number + readonly error: unknown + readonly kind: 'crashed' + readonly lastProgress: Progress | undefined + } + +type ParentMessage = { readonly input: Input; readonly type: 'task' } + +type WorkerMessage = + | { readonly error: unknown; readonly type: 'error' } + | { readonly progress: Progress; readonly type: 'progress' } + | { readonly result: Result; readonly type: 'result' } + +interface RunningTask { + deadlineHit: boolean + lastProgress: Progress | undefined + readonly onProgress: ((progress: Progress) => void) | undefined + readonly settle: (outcome: BoundedWorkerOutcome) => void + readonly startedAt: number + readonly timer: NodeJS.Timeout +} + +const OUT_OF_MEMORY_CODE = 'ERR_WORKER_OUT_OF_MEMORY' + +function isOutOfMemory(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === OUT_OF_MEMORY_CODE +} + +interface BoundedWorkerOptions { + readonly entry: string | URL + readonly heapMb: number +} + +interface RunOptions { + readonly deadlineMs: number + readonly onProgress?: (progress: Progress) => void +} + +export class BoundedWorker { + private readonly entry: string | URL + private readonly exited: Promise + private exitCode: number | undefined + /** The first 'error' event: the heap cap, or a throw outside the task handler. */ + private failure: unknown + private running: RunningTask | undefined + private readonly worker: Worker + + constructor({ entry, heapMb }: BoundedWorkerOptions) { + this.entry = entry + this.worker = new Worker(entry, { resourceLimits: { maxOldGenerationSizeMb: heapMb } }) + this.worker.on('message', (message: WorkerMessage) => + this.handleMessage(message), + ) + this.worker.on('error', (error) => { + this.failure ??= error + }) + this.exited = new Promise((resolve) => { + this.worker.once('exit', (exitCode) => { + this.exitCode = exitCode + this.settleOnExit() + resolve() + }) + }) + } + + run(input: Input, { deadlineMs, onProgress }: RunOptions) { + return new Promise>((settle) => { + const startedAt = performance.now() + if (this.running !== undefined || this.exitCode !== undefined) { + settle({ + elapsedMs: 0, + error: new Error( + this.running === undefined + ? `The worker running ${String(this.entry)} already stopped; start a new BoundedWorker.` + : `The worker running ${String(this.entry)} is still busy with another task.`, + ), + kind: 'crashed', + lastProgress: undefined, + }) + return + } + const timer = setTimeout(() => { + if (this.running !== undefined) { + this.running.deadlineHit = true + } + void this.worker.terminate() + }, deadlineMs) + this.running = { + deadlineHit: false, + lastProgress: undefined, + onProgress, + settle, + startedAt, + timer, + } + this.worker.postMessage({ input, type: 'task' } satisfies ParentMessage) + }) + } + + /** Stops the worker and resolves once it has exited. */ + async close(): Promise { + await this.worker.terminate() + await this.exited + } + + private finish(outcome: BoundedWorkerOutcome): void { + const running = this.running + if (running === undefined) { + return + } + clearTimeout(running.timer) + this.running = undefined + running.settle(outcome) + } + + private handleMessage(message: WorkerMessage): void { + const running = this.running + /** + * Once the deadline timer has fired, the task's outcome is already decided as a stop: a + * message that arrives afterward (a result or an error the worker was already mid-flight on + * when terminate() was requested) settles nothing here, so only the worker's own exit event + * (settleOnExit) ever finishes the task, deterministically as 'deadline'. Without this guard, a + * late 'result' or 'error' message races that exit event: whichever the parent's event loop + * happens to process first decides the outcome, so the same regression that trips the deadline + * could still read as 'done', and a BoundedWorker reused for a later task (compare-release.ts's + * BuildRunner) would then find the worker already exited underneath it. + */ + if (running === undefined || running.deadlineHit) { + return + } + const elapsedMs = performance.now() - running.startedAt + switch (message.type) { + case 'progress': + running.lastProgress = message.progress + running.onProgress?.(message.progress) + return + case 'result': + this.finish({ elapsedMs, kind: 'done', result: message.result }) + return + case 'error': + this.finish({ + elapsedMs, + error: message.error, + kind: 'crashed', + lastProgress: running.lastProgress, + }) + } + } + + private settleOnExit(): void { + const running = this.running + if (running === undefined) { + return + } + const elapsedMs = performance.now() - running.startedAt + const { lastProgress } = running + if (isOutOfMemory(this.failure)) { + this.finish({ elapsedMs, kind: 'heap', lastProgress }) + } else if (this.failure !== undefined) { + this.finish({ elapsedMs, error: this.failure, kind: 'crashed', lastProgress }) + } else if (running.deadlineHit) { + this.finish({ elapsedMs, kind: 'deadline', lastProgress }) + } else { + this.finish({ + elapsedMs, + error: new Error( + `The worker running ${String(this.entry)} exited with code ${String(this.exitCode)} ` + + 'before reporting a result. Something in it called process.exit.', + ), + kind: 'crashed', + lastProgress, + }) + } + } +} + +interface RunBoundedOptions { + readonly entry: string | URL + readonly input: Input + readonly limits: BoundedWorkerLimits + readonly onProgress?: (progress: Progress) => void +} + +/** Runs one task in a fresh worker and closes it. */ +export async function runBounded({ + entry, + input, + limits, + onProgress, +}: RunBoundedOptions): Promise> { + const worker = new BoundedWorker({ entry, heapMb: limits.heapMb }) + const outcome = await worker.run(input, { deadlineMs: limits.deadlineMs, onProgress }) + await worker.close() + return outcome +} + +/** + * The worker side: runs `handler` on each task's input and reports its result, or the error it + * threw. Call it only when `isMainThread` is false. + */ +export function serveBounded( + handler: (input: Input, reportProgress: (progress: Progress) => void) => Result | Promise, +): void { + if (isMainThread || parentPort === null) { + throw new Error( + 'serveBounded runs only inside a worker started by scripts/bounded-worker.ts; guard the ' + + 'call with `if (!isMainThread)`.', + ) + } + const port = parentPort + const post = (message: WorkerMessage) => port.postMessage(message) + const reportProgress = (progress: Progress) => post({ progress, type: 'progress' }) + port.on('message', async (message: ParentMessage) => { + try { + const result = await handler(message.input, reportProgress) + post({ result, type: 'result' }) + } catch (error) { + post({ error, type: 'error' }) + } + }) +} + +export function formatSeconds(milliseconds: number): string { + return `${(milliseconds / 1000).toFixed(1)}s` +} diff --git a/scripts/changelog.cjs b/scripts/changelog.cjs new file mode 100644 index 0000000..1e194e6 --- /dev/null +++ b/scripts/changelog.cjs @@ -0,0 +1,237 @@ +'use strict' +/** + * Changelog generator for changesets: cites every commit behind a changeset and + * thanks the contributors other than the maintainer. + * + * The stock generator (@changesets/cli/changelog) resolves one commit per + * changeset with `git log --diff-filter=A`, so a later commit that ships code + * while amending the same changeset is absent from the release notes and its + * author goes uncredited. This one asks for adds and modifications, oldest + * first, cites them all, and names the people behind them. + * + * Wired up by .changeset/config.json, which also supplies `maintainers`. + * @changesets/apply-release-plan loads it with import(), which takes either + * module system; test/unit/changelog.test.ts also require()s it from a plain + * `node -e` process to check stderr. + */ +const { execFileSync } = require('node:child_process') + +/** Where changesets keeps one file per pending change, relative to the repository root. */ +const CHANGESET_DIRECTORY = '.changeset' + +/** + * The revision filter every attribution query uses: commits that added or + * modified the file, oldest first. Shared with scripts/changeset-credits.ts so + * the release notes and the credits report never disagree about which commits + * belong to a changeset. + */ +const ATTRIBUTION_LOG_ARGS = ['log', '--reverse', '--diff-filter=AM'] + +/** + * Email fragments identifying an agent rather than a person. An agent usually + * acts for somebody, and that somebody is who a release note names, so agents + * are never thanked automatically. An agent that genuinely originated the work + * is creditable, but that is a judgment for whoever writes the changeset prose, + * not something derivable from a commit. + * + * Matched on the email alone: "Claude" is also a person's name, and silently + * dropping a human contributor is the failure worth avoiding here. + */ +const AGENT_EMAILS = ['copilot@', 'cursoragent', 'devin-ai-integration', 'noreply@anthropic.com'] + +/** ASCII separators; no git identity or subject line contains them. */ +const FIELD = '\x1f' +const RECORD = '\x02' +const TRAILER = '\x1e' + +const LOG_FORMAT = [ + '%h', + '%p', + '%an', + '%ae', + '%s', + `%(trailers:key=Co-authored-by,valueonly,separator=${TRAILER})`, +].join(FIELD) + +/** Repo-relative path of a changeset file, given the id changesets passes us. */ +function changesetPath(id) { + return `${CHANGESET_DIRECTORY}/${id}.md` +} + +/** True for an agent or automation identity, which is never thanked. */ +function isAgent(person) { + const email = person.email.toLowerCase() + return ( + person.name.toLowerCase().endsWith('[bot]') || + AGENT_EMAILS.some((fragment) => email.includes(fragment)) + ) +} + +/** `Name ` as git writes a Co-authored-by trailer. */ +function parseTrailer(value) { + const match = value.match(/^\s*(.*?)\s*<([^>]+)>\s*$/) + return match ? { email: match[2], name: match[1] } : null +} + +/** + * Who and what is behind a changeset file. + * + * `trustworthy` is false when any commit has no parent. In a shallow clone + * that is the boundary commit, where every file reads as having been added by + * it, and the credits would be confidently wrong; a repository's own root + * commit is parentless too, so this errs toward the fallback in the one case + * where the answer would have been right anyway. The release workflow checks + * out full history, so this stays unused in CI. + * + * Shapes are declared in scripts/changelog.d.cts, which is what the importing + * TypeScript sees. + */ +function attribution(file, options) { + /** + * Read explicitly rather than left to execFileSync's own default, so a test can stand in a + * directory by stubbing process.cwd() instead of changing the process's real one. + */ + const cwd = options?.cwd ?? process.cwd() + const revs = options?.revs ?? [] + + let stdout + try { + stdout = execFileSync( + 'git', + [...ATTRIBUTION_LOG_ARGS, ...revs, `--format=${RECORD}${LOG_FORMAT}`, '--', file], + /** stderr is piped, not inherited, so git's message reaches `error` instead of the terminal. */ + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ) + } catch (error) { + return { commits: [], people: [], trustworthy: false, error: error.message } + } + + const commits = [] + const people = [] + const seen = new Set() + let trustworthy = true + + const add = (person) => { + const key = person.email.toLowerCase() + if (seen.has(key)) { + return + } + seen.add(key) + people.push(person) + } + + for (const record of stdout.split(RECORD)) { + if (!record.trim()) { + continue + } + const [sha, parents, name, email, subject, trailers] = record.split(FIELD) + if (!parents) { + trustworthy = false + } + commits.push(sha) + + add({ email, name, sha, subject, via: 'author' }) + + for (const value of (trailers || '').split(TRAILER)) { + if (!value.trim()) { + continue + } + const person = parseTrailer(value) + if (person) { + add({ ...person, sha, subject, via: 'co-author' }) + } + } + } + + return { commits, people, trustworthy } +} + +/** True when `person` is one of the configured maintainers. */ +function isMaintainer(person, maintainers) { + const name = person.name.toLowerCase() + const email = person.email.toLowerCase() + return maintainers.some((entry) => { + const needle = entry.toLowerCase() + return name === needle || email.includes(needle) + }) +} + +const NAMES = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }) + +/** + * One release-note bullet: the contributing commits, the summary, then thanks + * for everyone who worked on it other than the maintainer and any agent. + * Mirrors the stock generator's shape (leading commit reference, continuation + * lines indented by two) so old and new entries read the same. + */ +const getReleaseLine = async (changeset, _type, options) => { + const maintainers = options?.maintainers || [] + const [firstLine, ...futureLines] = changeset.summary.split('\n').map((line) => line.trimEnd()) + + const found = attribution(changesetPath(changeset.id), {}) + const usable = found.trustworthy && found.commits.length > 0 + if (found.error) { + console.warn( + `changelog: git could not read the history of ${changesetPath(changeset.id)}, so ` + + 'no contributors are credited and the release line cites the single commit ' + + `changesets resolved. Check that git is installed and this runs inside the repository.\n${found.error}`, + ) + } else if (!usable && found.commits.length > 0) { + console.warn( + `changelog: ${changesetPath(changeset.id)} has a parentless commit in ` + + 'its history, which means a shallow clone. Falling back to the single ' + + 'commit changesets resolved; check out full history to fix.', + ) + } + + const cited = usable ? found.commits : changeset.commit ? [changeset.commit.slice(0, 7)] : [] + + let returnVal = `- ${cited.length > 0 ? `${cited.join(', ')}: ` : ''}${firstLine}` + + if (futureLines.length > 0) { + returnVal += `\n${futureLines.map((line) => ` ${line}`).join('\n')}` + } + + if (usable) { + const summary = changeset.summary.toLowerCase() + const thanks = found.people + .filter((person) => !isAgent(person)) + .filter((person) => !isMaintainer(person, maintainers)) + /** Hand-written prose in the changeset wins: never say a name twice. */ + .filter((person) => !summary.includes(person.name.toLowerCase())) + .map((person) => person.name) + + if (thanks.length > 0) { + returnVal += `\n\n Thanks ${NAMES.format(thanks)}!` + } + } + + return returnVal +} + +/** Unchanged from the stock generator: dependency bumps carry no attribution. */ +const getDependencyReleaseLine = async (changesets, dependenciesUpdated) => { + if (dependenciesUpdated.length === 0) { + return '' + } + + const changesetLinks = changesets.map( + (changeset) => + `- Updated dependencies${changeset.commit ? ` [${changeset.commit.slice(0, 7)}]` : ''}`, + ) + const updatedDependenciesList = dependenciesUpdated.map( + (dependency) => ` - ${dependency.name}@${dependency.newVersion}`, + ) + + return [...changesetLinks, ...updatedDependenciesList].join('\n') +} + +module.exports = { + CHANGESET_DIRECTORY, + attribution, + changesetPath, + getDependencyReleaseLine, + getReleaseLine, + isAgent, + isMaintainer, +} diff --git a/scripts/changelog.d.cts b/scripts/changelog.d.cts new file mode 100644 index 0000000..2836559 --- /dev/null +++ b/scripts/changelog.d.cts @@ -0,0 +1,80 @@ +/** + * Types for scripts/changelog.cjs. Without this file the importing script sees + * `any` for every export, and the two could disagree about the attribution + * shape with nothing to catch it. + */ + +/** Where changesets keeps one file per pending change, relative to the repository root. */ +export const CHANGESET_DIRECTORY: '.changeset' + +/** One person credited on a changeset, with the commit that put them there. */ +export interface Contributor { + email: string + name: string + sha: string + subject: string + via: 'author' | 'co-author' +} + +/** A git author or committer identity. */ +export type GitIdentity = Pick + +/** Who and what is behind a changeset file. */ +export interface Attribution { + /** Abbreviated hashes of every commit that added or modified the file. */ + commits: string[] + /** Message from git when the query itself failed. */ + error?: string + /** Authors and co-authors, first appearance first, deduplicated by email. */ + people: Contributor[] + /** + * False when any commit came back without a parent, which means a shallow + * clone where every file reads as added by the boundary commit. + */ + trustworthy: boolean +} + +/** One pending (or already-applied) changeset, as `@changesets/cli` passes it to the generator. */ +export interface Changeset { + /** The single commit `@changesets/cli` itself resolved; the fallback when attribution fails. */ + commit?: string + /** The changeset filename, minus `.md`. */ + id: string + releases: Array<{ name: string; type: 'major' | 'minor' | 'patch' }> + /** The changeset body, frontmatter already stripped. */ + summary: string +} + +export interface ChangelogOptions { + maintainers?: string[] +} + +/** One package whose dependency range moved as a side effect of another package's release. */ +export interface DependencyUpdate { + name: string + newVersion: string +} + +export function attribution(file: string, options?: { cwd?: string; revs?: string[] }): Attribution + +/** Repo-relative path of a changeset file, given its id. */ +export function changesetPath(id: string): string + +/** Unchanged from the stock generator: dependency bumps carry no attribution. */ +export function getDependencyReleaseLine( + changesets: Array>, + dependenciesUpdated: DependencyUpdate[], +): Promise + +/** One release-note bullet: the contributing commits, the summary, then a thanks line. */ +export function getReleaseLine( + changeset: Changeset, + type: 'major' | 'minor' | 'patch', + options?: ChangelogOptions, +): Promise + +/** True for an agent or automation identity, which is never thanked. */ +export function isAgent(person: GitIdentity): boolean + +/** True when the person is one of the configured maintainers. */ +export function isMaintainer(person: GitIdentity, maintainers: string[]): boolean diff --git a/scripts/changeset-credits.ts b/scripts/changeset-credits.ts new file mode 100644 index 0000000..e78a1d5 --- /dev/null +++ b/scripts/changeset-credits.ts @@ -0,0 +1,150 @@ +/** + * List everyone who introduced or edited a changeset file, so a credit line in + * the changeset is derived from git rather than remembered. + * + * Attribution lives in the commits: an outside fix lands with the contributor + * as git author even when a maintainer rewrites and commits it, and a helper + * who is not the author lands as a Co-authored-by trailer. Both are reported. + * + * The query itself lives in scripts/changelog.cjs, which the release notes use + * too, so the report and the published notes can never disagree about which + * commits belong to a changeset. + * + * Usage: node --experimental-strip-types scripts/changeset-credits.ts + * node --experimental-strip-types scripts/changeset-credits.ts quick-moons-invent + * node --experimental-strip-types scripts/changeset-credits.ts .changeset/process-env-define.md + * + * A released changeset still resolves: the release commit deletes the file, but + * the path keeps its history, and the delete is filtered out. + */ +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { CHANGESET_DIRECTORY, attribution, changesetPath, isAgent } from './changelog.cjs' +import type { Contributor } from './changelog.cjs' + +/** How a person is written in the report, and what the column is sized to. */ +const who = (credit: Contributor) => `${credit.name} <${credit.email}>` + +const REPO_ROOT = path.resolve(import.meta.dirname, '..') + +/** + * Resolve an argument to a repo-relative changeset path. Accepts a bare + * changeset name, a path, or a path with or without the `.md` suffix. + */ +function resolveChangeset(arg: string): string { + if (arg.includes('/')) { + return arg.endsWith('.md') ? arg : `${arg}.md` + } + return changesetPath(arg.replace(/\.md$/, '')) +} + +/** Every pending changeset, in name order. README.md is changesets' own docs. */ +function pendingChangesets(): string[] { + const dir = path.join(REPO_ROOT, CHANGESET_DIRECTORY) + if (!fs.existsSync(dir)) { + return [] + } + return fs + .readdirSync(dir) + .filter((name) => name.endsWith('.md') && name !== 'README.md') + .sort() + .map((name) => path.join(CHANGESET_DIRECTORY, name)) +} + +/** + * Credits plus where they were found. The distinction is load-bearing: a + * changeset whose only commits sit on an unmerged branch (a contributor's PR + * fetched locally, a squash-merge that renamed the file) has real authors, and + * reporting that as "no history" reads as "nobody to credit". A path with no + * history anywhere is usually a mistyped name, not a new changeset. + */ +function creditsFor(file: string): { + credits: Contributor[] + scope: 'all' | 'head' | 'none' +} { + const fromHead = attribution(file, { cwd: REPO_ROOT }) + if (fromHead.error) { + throw new Error( + `git could not read the history of ${file}; check that git is installed and this runs inside the repository.\n${fromHead.error}`, + ) + } + if (fromHead.people.length > 0) { + return { credits: fromHead.people, scope: 'head' } + } + + const fromAll = attribution(file, { cwd: REPO_ROOT, revs: ['--all'] }) + return fromAll.people.length > 0 + ? { credits: fromAll.people, scope: 'all' } + : { credits: [], scope: 'none' } +} + +/** + * The changeset's own summary text, minus the frontmatter block, or null when + * the file is not in the tree. Absence alone does not mean released: the same + * state covers a name that was mistyped, so the caller decides what to say. + */ +function summaryOf(file: string): string | null { + const abs = path.join(REPO_ROOT, file) + if (!fs.existsSync(abs)) { + return null + } + const body = fs.readFileSync(abs, 'utf8').replace(/^---[\s\S]*?---\n/, '') + return body.trim().split('\n')[0] || '(empty)' +} + +const args = process.argv.slice(2) +const files = args.length > 0 ? args.map(resolveChangeset) : pendingChangesets() + +if (files.length === 0) { + console.log('no changesets found') + process.exit(0) +} + +for (const file of files) { + console.log(path.basename(file, '.md')) + + const summary = summaryOf(file) + const { credits, scope } = creditsFor(file) + + if (summary !== null) { + console.log(` ${summary}`) + } else if (scope !== 'none') { + console.log(' (released; file no longer in the tree)') + } + + if (scope === 'none') { + console.log( + summary === null + ? ' (unknown: no such file and no history under this path)' + : ' (uncommitted: no git history for this file yet)', + ) + console.log('') + continue + } + if (scope === 'all') { + console.log(' (history not reachable from HEAD; read from all refs)') + } + + const width = Math.max(...credits.map((c) => who(c).length)) + const line = (credit: Contributor) => { + const name = who(credit).padEnd(width) + const via = credit.via === 'co-author' ? ' [co-author]' : '' + console.log(` ${name} ${credit.sha} ${credit.subject}${via}`) + } + + for (const credit of credits.filter((c) => !isAgent(c))) { + line(credit) + } + + const agents = credits.filter(isAgent) + if (agents.length > 0) { + console.log(' not credited (agent or automation; credit the person it') + console.log(' acted for, unless it originated the work itself):') + for (const agent of agents) { + line(agent) + } + } + console.log('') +} diff --git a/scripts/check-pack-contents.ts b/scripts/check-pack-contents.ts new file mode 100644 index 0000000..7f62403 --- /dev/null +++ b/scripts/check-pack-contents.ts @@ -0,0 +1,204 @@ +/** + * Asserts the exact file set of the packed tarball `corepack yarn pack:artifact` writes (the + * packer `yarn npm publish` uses) against the set this script derives from package.json. Fails + * listing every missing and every unexpected file. + * + * The expected set is: + * - every path package.json points a consumer at (`main`, `types`, each `exports` and + * `typesVersions` target), so a new entry point is expected as soon as package.json names it; + * - BUILD_HOOK_FILES, which tsdown.config.ts's build hooks write and package.json never names; + * - ALWAYS_PACKED_FILES, which Yarn packs from the package root whatever `files` says. + * A missing path outside every package.json `files` entry is flagged as such, since the packer + * can never include it. Paths are compared literally, so a pattern in package.json + * (findUnsupportedPatterns) fails the check by name before the tarball is read. + * + * Reads the tarball as it is: pack again after changing lib/ or package.json. + * + * Usage: node --experimental-strip-types scripts/check-pack-contents.ts [tarball] + * The tarball defaults to package-artifact/package.tgz. + */ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const REPO_ROOT = path.resolve(import.meta.dirname, '..') +const DEFAULT_TARBALL = path.join(REPO_ROOT, 'package-artifact', 'package.tgz') + +/** Yarn packs these from the package root regardless of `files` (package.json, README, LICENSE, CHANGELOG). */ +const ALWAYS_PACKED_FILES = ['CHANGELOG.md', 'LICENSE.txt', 'README.md', 'package.json'] + +/** + * Written by tsdown.config.ts's build:done hook and never named in package.json: the classic + * Node10 declaration copy for a `lib/index` deep import, and the nested `{"type": "commonjs"}` + * marker that keeps lib/index.js CommonJS under the root's "type": "module". + */ +const BUILD_HOOK_FILES = ['lib/index.d.ts', 'lib/package.json'] + +/** The directory every entry in a Yarn-packed tarball sits under. */ +const TARBALL_ROOT = 'package/' + +interface Manifest { + exports?: unknown + files?: string[] + main?: string + types?: string + typesVersions?: unknown +} + +type LeafVisitor = (leaf: string, keys: readonly string[]) => void + +function normalize(target: string): string { + return target.replace(/^\.\//, '') +} + +/** Calls `visit` for every string leaf under `value`, with the keys (and list indexes) leading to it. */ +function forEachLeaf(value: unknown, keys: readonly string[], visit: LeafVisitor): void { + if (typeof value === 'string') { + visit(value, keys) + } else if (Array.isArray(value)) { + value.forEach((entry, index) => forEachLeaf(entry, [...keys, String(index)], visit)) + } else if (value !== null && typeof value === 'object') { + for (const [key, entry] of Object.entries(value)) { + forEachLeaf(entry, [...keys, key], visit) + } + } +} + +function expectedFiles(manifest: Manifest): Set { + const expected = new Set([...ALWAYS_PACKED_FILES, ...BUILD_HOOK_FILES]) + for (const target of [manifest.main, manifest.types]) { + if (target !== undefined) { + expected.add(normalize(target)) + } + } + const add: LeafVisitor = (leaf) => expected.add(normalize(leaf)) + forEachLeaf(manifest.exports, [], add) + forEachLeaf(manifest.typesVersions, [], add) + return expected +} + +const FILES_GLOB_PATTERN = /[*?[\]{}]/ + +/** + * Every package.json value this script would otherwise compare as a literal path although it is a + * pattern: a `files` glob or negation, an `exports` or `typesVersions` wildcard (`*`), or an + * `exports` folder mapping (a key or target ending in "/"). Each would yield a misleading missing + * or unexpected list, so the check stops and names it instead. + */ +function findUnsupportedPatterns(manifest: Manifest): string[] { + const found = new Set() + for (const entry of manifest.files ?? []) { + if (entry.startsWith('!') || FILES_GLOB_PATTERN.test(entry)) { + found.add(`"files" entry ${JSON.stringify(entry)} (a glob or negation)`) + } + } + forEachLeaf(manifest.exports, [], (leaf, keys) => { + for (const key of keys) { + if (key.startsWith('.') && (key.includes('*') || key.endsWith('/'))) { + found.add(`"exports" key ${JSON.stringify(key)} (a wildcard or folder pattern)`) + } + } + if (leaf.includes('*') || leaf.endsWith('/')) { + found.add( + `"exports" target ${JSON.stringify(leaf)} at exports${keys.map((key) => `[${JSON.stringify(key)}]`).join('')} (a wildcard or folder pattern)`, + ) + } + }) + forEachLeaf(manifest.typesVersions, [], (leaf, keys) => { + const subpath = keys[1] ?? '' + if (subpath.includes('*') || leaf.includes('*')) { + found.add(`"typesVersions" mapping ${JSON.stringify(subpath)} (a wildcard pattern)`) + } + }) + return [...found] +} + +function isUnderFilesEntry(file: string, entries: readonly string[]): boolean { + return entries.some((entry) => { + const prefix = normalize(entry).replace(/\/$/, '') + return file === prefix || file.startsWith(`${prefix}/`) + }) +} + +function packedFiles(tarball: string): Set { + const listing = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) + if (listing.error || listing.status !== 0) { + throw new Error( + `\`tar -tzf ${tarball}\` failed (${listing.error ?? `exit code ${listing.status}`}): ${listing.stderr}`, + ) + } + const files = new Set() + for (const entry of listing.stdout.split('\n')) { + if (entry === '' || entry.endsWith('/')) { + continue + } + if (!entry.startsWith(TARBALL_ROOT)) { + throw new Error( + `${tarball} holds ${entry}, outside the ${TARBALL_ROOT} directory every packed file sits under.`, + ) + } + files.add(entry.slice(TARBALL_ROOT.length)) + } + if (files.size === 0) { + throw new Error(`${tarball} lists no files, so there is nothing to compare.`) + } + return files +} + +function main(): void { + const tarball = path.resolve(process.argv[2] ?? DEFAULT_TARBALL) + if (!fs.existsSync(tarball)) { + console.error( + `Pack check: no packed tarball at ${tarball}. Run "corepack yarn pack:artifact" (it builds ` + + 'lib/ first), or pass a tarball path.', + ) + process.exitCode = 1 + return + } + const manifest = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'), + ) as Manifest + const unsupported = findUnsupportedPatterns(manifest) + if (unsupported.length > 0) { + console.error( + `Pack check: package.json uses patterns this script compares only as literal paths:\n ` + + `${unsupported.join('\n ')}\n` + + 'Its missing and unexpected lists would be wrong for these. Name each file literally in ' + + 'package.json, or teach scripts/check-pack-contents.ts to expand the pattern.', + ) + process.exitCode = 1 + return + } + const expected = expectedFiles(manifest) + const packed = packedFiles(tarball) + const missing = [...expected].filter((file) => !packed.has(file)).sort() + const unexpected = [...packed].filter((file) => !expected.has(file)).sort() + + if (missing.length > 0) { + const describeMissing = (file: string) => + ALWAYS_PACKED_FILES.includes(file) || isUnderFilesEntry(file, manifest.files ?? []) + ? file + : `${file} (outside every package.json "files" entry, ${JSON.stringify(manifest.files)}, so the tarball can never contain it)` + console.error( + `Pack check: missing from the tarball:\n ${missing.map(describeMissing).join('\n ')}\n` + + 'Either the build no longer writes these (check tsdown.config.ts), or package.json ' + + '"files" does not cover them: add the directory to "files", or move the entry point ' + + '(package.json) or build hook output (BUILD_HOOK_FILES in this script) under one.', + ) + } + if (unexpected.length > 0) { + console.error( + `Pack check: unexpected in the tarball:\n ${unexpected.join('\n ')}\n` + + 'Either the build writes a file nothing points at (remove it, or name it in package.json), ' + + 'or a stray file at the package root matches a name Yarn always packs.', + ) + } + if (missing.length > 0 || unexpected.length > 0) { + process.exitCode = 1 + return + } + console.log(`ok pack contents: ${packed.size} files, exactly the expected set`) +} + +main() diff --git a/scripts/compare-release-cases.ts b/scripts/compare-release-cases.ts new file mode 100644 index 0000000..6552bcb --- /dev/null +++ b/scripts/compare-release-cases.ts @@ -0,0 +1,267 @@ +/** + * Default cases for scripts/compare-release.ts: shapes whose diagnostics, completions, or code + * fixes changed on purpose since the last release, so a run shows each difference next to what + * the release did. The format is documented in docs/maintenance.md, "Comparing with a release". + */ + +export interface CompareCase { + readonly code: string + readonly config?: Readonly> + readonly ext?: string + readonly name: string + readonly ops?: readonly CompareOperation[] +} + +export type CompareOperation = + | { readonly at: string; readonly op: 'comp'; readonly show?: number } + | { readonly at: string; readonly op: 'hover' } + | { readonly configuration: unknown; readonly op: 'configure' } + | { readonly from: string; readonly op: 'change'; readonly text: string; readonly to?: string } + | { readonly op: 'diag' } + | { readonly op: 'fix' } + | { readonly op: 'fold' } + +const HEADER = "import styled, { css, keyframes } from 'styled-components'\n" + +function diagnosticsCase(name: string, body: string): CompareCase { + return { code: HEADER + body, name, ops: [{ op: 'diag' }, { op: 'fix' }] } +} + +const SELECTOR_STOP_DECLARATIONS = + "import { createGlobalStyle } from 'styled-components'\ndeclare const Link: string, Other: string, p: string, q: string, v: string\n" + +/** + * A ";", "{", "}", or "&" inside a comment, string, or unquoted url() argument between a + * placeholder's ":" and what follows, which must not decide whether the placeholder is a selector + * or a property name. + */ +function selectorStopCases(): CompareCase[] { + const rule = '${Link}:hover /* a; b */ { color: red; }' + const entries: ReadonlyArray = [ + ['block comment holding ";"', 'styled.div`${Link}:hover /* nav; link */ { color: red; }`'], + ['block comment holding "}"', 'styled.div`${Link}:hover /* } */ { color: red; }`'], + ['line comment holding ";"', 'styled.div`\n ${Link}:hover // nav; link\n { color: red; }\n`'], + ['double-quoted string holding ";"', 'styled.div`${Link}:not([data-x="a;b"]) { color: red; }`'], + ["single-quoted string holding '}'", "styled.div`${Link}:not([data-x='a}b']) { color: red; }`"], + [ + 'string holding "//" and ";"', + 'styled.div`${Link}:not([href^="http://a;b"]) { color: red; }`', + ], + [ + 'selector list with a comment holding ";"', + 'styled.div`${Link}:hover, /* a; b */ ${Other}:focus { color: red; }`', + ], + [ + 'selector list whose line ends in "," and a line comment', + 'styled.div`\n ${Link}:hover, // first\n ${Other}:focus {\n color: red;\n }\n`', + ], + ['placeholder joined to a class name', 'styled.div`&.${v}:hover /* a; b */ { color: red; }`'], + ['css fragment', `css\`${rule}\``], + ['createGlobalStyle', `createGlobalStyle\`body { ${rule} }\``], + ['nested block', `styled.div\`&:focus { ${rule} }\``], + ['@media body', `styled.div\`@media (min-width: 1px) { ${rule} }\``], + ['@supports body', `styled.div\`@supports (display: grid) { ${rule} }\``], + ['property name before a string holding "{"', 'styled.div`${p}: "a{b"; color: red;`'], + ['property name before a comment holding "&"', 'styled.div`${p}: 0 /* & */; color: red;`'], + ['property name before a url() holding "{"', 'styled.div`${p}: url(a{b.png); color: red;`'], + [ + 'media condition after a comment holding ";"', + 'styled.div`@media screen and /* a; b */ ${q} { color: red; }`', + ], + ] + return entries.map(([name, template]) => + diagnosticsCase( + `selector scan past a ${name}`, + `${SELECTOR_STOP_DECLARATIONS}const A = ${template}`, + ), + ) +} + +const NEXT_CHARACTER_COMMENT_DECLARATIONS = 'declare const Child: string, p: string\n' + +/** + * A comment between a placeholder and the character right after it, or right after it on the next + * line, that decides the placeholder's role: a selector's "{", a property name's ":", or the "{" + * that opens a rule body on the line after a pseudo-class. + */ +function nextCharacterCommentCases(): CompareCase[] { + const entries: ReadonlyArray = [ + [ + 'comment between a selector placeholder and its body', + 'styled.div`\n ${Child} /* note */ {\n color: red;\n }\n`', + ], + [ + 'comment on the line before a rule body after a pseudo-class', + 'styled.div`\n ${Child}:hover\n /* note */ {\n color: red;\n }\n`', + ], + [ + 'comment between a property-name placeholder and its colon', + 'styled.div`\n ${p} /* note */: red;\n`', + ], + ] + return entries.map(([name, template]) => + diagnosticsCase( + `next-character scan past a ${name}`, + `${NEXT_CHARACTER_COMMENT_DECLARATIONS}const A = ${template}`, + ), + ) +} + +const cases: CompareCase[] = [ + diagnosticsCase('escape at a property name start', 'const A = styled.div`\\x63olr: red;`'), + diagnosticsCase('escape inside a property name', 'const A = styled.div`co\\x6Cr: red;`'), + diagnosticsCase('escape after a property name', 'const A = styled.div`colr\\x3a red;`'), + { + code: `${HEADER}const A = styled.div\`\\x63ol⟨a⟩\``, + name: 'completion after an escape', + ops: [{ at: 'a', op: 'comp', show: 3 }], + }, + diagnosticsCase( + 'keyframe selector placeholder first', + 'declare const a: number\nconst k = keyframes`${a}% { opacity: 0; } to { opacity: 1; }`', + ), + diagnosticsCase( + 'keyframe selector placeholder second', + 'declare const a: number\nconst k = keyframes`\n 0% { opacity: 0; }\n ${a}% { opacity: 1; }\n`', + ), + diagnosticsCase( + 'selector list of placeholders with pseudo-classes over lines', + 'declare const C: string\nconst A = styled.div`\n ${C}:hover,\n ${C}:focus {\n color: red;\n }\n`', + ), + diagnosticsCase( + 'selector list ending in a parent selector over lines', + 'declare const C: string\nconst A = styled.div`\n ${C}:hover,\n &:focus-visible {\n color: red;\n }\n`', + ), + diagnosticsCase( + 'value continued after a comma over lines', + 'declare const a: string, b: string\nconst A = styled.div`\n transition:\n ${a},\n ${b};\n font-family: a,\n ${b};\n`', + ), + diagnosticsCase( + 'media conditions on both sides of "or"', + 'declare const q: string\nconst A = styled.div`@media ${q} or ${q} { color: red; }`', + ), + diagnosticsCase( + 'media prelude after a comment', + 'declare const q: string\nconst A = styled.div`/* x */ @media screen and ${q} { color: red; }`', + ), + diagnosticsCase( + 'comment between "and" and a media condition', + 'declare const q: string\nconst A = styled.div`@media screen and /* x */ ${q} { color: red; }`', + ), + diagnosticsCase( + 'escaped paren before a hex digit in url()', + 'const A = styled.div`background: url(a\\\\(b\\\\).png);`', + ), + diagnosticsCase( + 'escaped paren before a letter in url()', + 'const A = styled.div`background: url(a\\\\(z\\\\).png);`', + ), + { + code: `${HEADER}const A = styled.div\`\n background: url(/* " */a.png); m10⟨a⟩\n\``, + name: 'completion after a url() whose leading comment holds a quote', + ops: [{ at: 'a', op: 'comp', show: 3 }], + }, + diagnosticsCase( + 'css value whose url() leading comment holds ";"', + 'const f = css`url(/* ; */a.png) no-repeat`', + ), + diagnosticsCase( + 'escape inside a known property name', + 'const A = styled.div`ba\\x63kground: red;`', + ), + diagnosticsCase( + 'escape before a hex digit inside a name', + 'const A = styled.div`bor\\x64er: 0;`', + ), + diagnosticsCase('escape inside a value keyword', 'const A = styled.div`color: r\\x65d;`'), + diagnosticsCase( + 'escaped CSS colon inside a class name', + 'const A = styled.div`.md\\\\:flex { color: red; }`', + ), + diagnosticsCase('css single keyword', 'const f = css`red`'), + diagnosticsCase('css single keyword "none"', 'const f = css`none`'), + diagnosticsCase('css placeholder with a unit', 'declare const x: number\nconst f = css`${x}px`'), + diagnosticsCase('css single unknown name', 'const f = css`colr`'), + diagnosticsCase('css declaration with an unknown property', 'const f = css`colr: red;`'), + { + code: `${HEADER}const f = css\`dis⟨a⟩\``, + name: 'css single name still completes properties', + ops: [{ at: 'a', op: 'comp', show: 3 }], + }, + diagnosticsCase( + 'css value whose url() holds a comment with ";"', + 'const f = css`url(a/*;*/b.png) no-repeat`', + ), + diagnosticsCase('css value whose url() holds "}"', 'const f = css`url(a}b.png) no-repeat`'), + diagnosticsCase( + 'url() holding "{" before a declaration', + 'const A = styled.div`background: url(a{b.png); color: red;`', + ), + diagnosticsCase( + 'stray brace after a url() holding "}"', + 'const A = styled.div`background: url(a}b.png); color: red; }`', + ), + diagnosticsCase( + 'nested @layer after a url() holding "}"', + "import { createGlobalStyle } from 'styled-components'\nconst G = createGlobalStyle`body { background: url(a}b.png); @layer x { color: red; } }`", + ), + diagnosticsCase( + 'placeholder inside a url() after ";"', + 'declare const x: string\nconst A = styled.div`background: url(a;${x}.png) no-repeat;`', + ), + { + code: `${HEADER}const A = styled.div\`background: url(data:image/svg+xml;utf8,) no-repeat m10⟨a⟩\``, + name: 'completion after a url() holding ":" and ";"', + ops: [{ at: 'a', op: 'comp', show: 3 }], + }, + { + code: `${HEADER}const A = styled.div\`background: url(data:image/svg+xml;⟨a⟩utf8,) no-repeat\``, + name: 'completion inside a url() right after ";"', + ops: [{ at: 'a', op: 'comp', show: 3 }], + }, + { + code: `${HEADER}const A = styled.div\`background: url(icon.svg) no-repeat m10⟨a⟩\``, + name: 'completion after a plain url()', + ops: [{ at: 'a', op: 'comp', show: 3 }], + }, + diagnosticsCase( + 'escape after a url character that is no name character', + 'const A = styled.div`background: url(a/\\x41y.png);`', + ), + diagnosticsCase( + 'escape inside a url() name', + 'const A = styled.div`background: url(x\\u0041y.png);`', + ), + diagnosticsCase( + 'line continuation inside a url()', + 'const A = styled.div`background: url(foo\\\nbar.png);\ncolor: red;`', + ), + diagnosticsCase( + 'property name placeholder before "&" on the same line', + 'declare const s: string\nconst A = styled.div`padding-${s}: 0; &:hover { color: red; }`', + ), + diagnosticsCase( + 'property name suffix placeholder before "&" on the same line', + 'declare const s: string\nconst A = styled.div`${s}-top: 0; &:hover { color: red; }`', + ), + diagnosticsCase( + 'property name middle placeholder before "&" on the same line', + 'declare const s: string\nconst A = styled.div`border-${s}-color: red; &:hover { color: red; }`', + ), + diagnosticsCase( + 'whole property name placeholder before "&" on the same line', + 'declare const p: string\nconst A = styled.div`${p}: 0; &:hover { color: red; }`', + ), + ...selectorStopCases(), + ...nextCharacterCommentCases(), + { + ...diagnosticsCase('capitalized lint level', 'const A = styled.div`&:hover { }`'), + config: { lint: { emptyRules: 'Error' } }, + }, + { + ...diagnosticsCase('lowercase lint level', 'const A = styled.div`&:hover { }`'), + config: { lint: { emptyRules: 'error' } }, + }, +] + +export default cases diff --git a/scripts/compare-release.ts b/scripts/compare-release.ts new file mode 100644 index 0000000..058dbcf --- /dev/null +++ b/scripts/compare-release.ts @@ -0,0 +1,773 @@ +/** + * Compare-release sensor: runs the same templates through the working build (lib/, from + * `corepack yarn compile`) and a published release of this package, each loaded as tsserver loads + * a plugin but inside an in-memory TypeScript language service, and prints every diagnostic, + * completion list, hover, folding result, and code fix that differs. + * + * Usage: `corepack yarn compare-release [--version 1.0.1] [--all] [case-module ...]` + * - A case module's default export is an array of cases (CompareCase in + * scripts/compare-release-cases.ts, the default module when none is given). `⟨name⟩` in a case's + * code marks a position (stripped before the code is used); `ops` defaults to one `diag`. + * - `--version` picks the release (default 1.0.1). It is fetched once with `npm pack + * --ignore-scripts` and extracted under node_modules/.cache/compare-release//, where it + * resolves its dependencies from this repository's own node_modules: nothing is installed and no + * package script runs. + * - `--all` prints every case, not only the ones that differ. + * - `--filter ` runs only the cases whose name matches, such as `'^(?!huge)'`. + * - Each build runs in its own worker thread, both builds of a case at once, and each case is + * stopped at CASE_DEADLINE_MS or CASE_HEAP_CAP_MB (scripts/bounded-worker.ts). A stop is that + * build's result for the operation it stopped in, printed like a throw, and the run goes on in a + * fresh worker. + * + * Fails loudly (exit 1) when lib/ is missing or older than src/, when either build does not load, + * or when either build reports nothing for a positive-control template with a misspelled property, + * which a plugin that silently failed to load would also do, or stops on it. + * + * Blind spots: tsserver itself is not involved (no protocol, no closed-file handling, no project + * loading); positions after a `change` op still come from the markers of the original code; the + * release runs against this repository's dependency versions, not the ones a fresh install of it + * would resolve. + */ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { isMainThread } from 'node:worker_threads' + +import ts from 'typescript' + +import { BoundedWorker, formatSeconds, serveBounded } from './bounded-worker.ts' +import type { CompareCase, CompareOperation } from './compare-release-cases.ts' + +const REPOSITORY_ROOT = path.resolve(import.meta.dirname, '..') +const PACKAGE_NAME = '@styled/typescript-styled-plugin' +const DEFAULT_VERSION = '1.0.1' +const PLUGIN_SOURCE = 'ts-styled-plugin' +const DEFAULT_CASES = path.join(REPOSITORY_ROOT, 'scripts', 'compare-release-cases.ts') +const CACHE_ROOT = path.join(REPOSITORY_ROOT, 'node_modules', '.cache', 'compare-release') +const WORKING_ENTRY = path.join(REPOSITORY_ROOT, 'lib', 'index.js') +const VIRTUAL_DIRECTORY = '/compare-release' +const DEFAULT_COMPLETION_SHOW = 10 +/** Longest spanned text a result line quotes; a folding range can cover a whole template. */ +const MAX_QUOTED_LENGTH = 80 +const MARKER_PATTERN = /⟨([^⟩]*)⟩/g + +const CONTROL_CASE: CompareCase = { + code: "import styled from 'styled-components'\nconst A = styled.div`\n colr: red;\n`", + name: 'positive control', +} +const CONTROL_MESSAGE = "Unknown property: 'colr'" + +interface PluginModule { + create(info: object): ts.LanguageService + onConfigurationChanged?(configuration: unknown): void +} + +interface Build { + readonly factory: (modules: { typescript: typeof ts }) => unknown + readonly label: string +} + +class CompareReleaseError extends Error {} + +function fail(message: string): never { + throw new CompareReleaseError(message) +} + +function parseArguments(argv: readonly string[]) { + let version = DEFAULT_VERSION + let showAll = false + let filter: RegExp | undefined + const caseModules: string[] = [] + for (let index = 0; index < argv.length; index++) { + const argument = argv[index] ?? '' + if (argument === '--all') { + showAll = true + } else if (argument === '--version') { + version = argv[++index] ?? fail('--version needs a value, such as --version 1.0.1.') + } else if (argument === '--filter') { + filter = parseFilter(argv[++index] ?? fail('--filter needs a regular expression.')) + } else if (argument.startsWith('--')) { + fail(`Unknown option ${argument}. Options: --version , --filter , --all.`) + } else { + caseModules.push(path.resolve(argument)) + } + } + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + fail(`--version ${version} is not a release version such as 1.0.1.`) + } + return { + caseModules: caseModules.length > 0 ? caseModules : [DEFAULT_CASES], + filter, + showAll, + version, + } +} + +function parseFilter(source: string): RegExp { + try { + return new RegExp(source) + } catch (error) { + fail(`--filter ${source} is not a valid regular expression: ${String(error)}`) + } +} + +/** The newest modification time under `directory`, in milliseconds. */ +function newestModification(directory: string): number { + let newest = 0 + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name) + const modified = entry.isDirectory() + ? newestModification(entryPath) + : statSync(entryPath).mtimeMs + newest = Math.max(newest, modified) + } + return newest +} + +function checkWorkingBuild(): void { + if (!existsSync(WORKING_ENTRY)) { + fail(`The working build is missing (${WORKING_ENTRY}). Run corepack yarn compile first.`) + } + if (newestModification(path.join(REPOSITORY_ROOT, 'src')) > statSync(WORKING_ENTRY).mtimeMs) { + fail( + 'lib/ is older than src/, so it would compare stale code. Run corepack yarn compile first.', + ) + } +} + +/** The release's lib/index.js, fetched and extracted into the cache on first use. */ +function ensureRelease(version: string): string { + const versionDirectory = path.join(CACHE_ROOT, version) + const entry = path.join(versionDirectory, 'package', 'lib', 'index.js') + if (existsSync(entry)) { + return entry + } + const workDirectory = mkdtempSync(path.join(tmpdir(), 'compare-release-')) + try { + execFileSync( + 'npm', + [ + 'pack', + `${PACKAGE_NAME}@${version}`, + '--ignore-scripts', + '--pack-destination', + workDirectory, + ], + { cwd: workDirectory, stdio: ['ignore', 'ignore', 'inherit'] }, + ) + const tarball = readdirSync(workDirectory).find((name) => name.endsWith('.tgz')) + if (!tarball) { + fail(`npm pack ${PACKAGE_NAME}@${version} wrote no tarball to ${workDirectory}.`) + } + const extracted = path.join(workDirectory, 'extracted') + mkdirSync(extracted) + execFileSync('tar', ['-xzf', path.join(workDirectory, tarball), '-C', extracted]) + mkdirSync(CACHE_ROOT, { recursive: true }) + rmSync(versionDirectory, { force: true, recursive: true }) + renameSync(extracted, versionDirectory) + } finally { + rmSync(workDirectory, { force: true, recursive: true }) + } + if (!existsSync(entry)) { + fail(`The ${version} tarball has no lib/index.js (looked for ${entry}).`) + } + return entry +} + +function requireEntry(entry: string, label: string): unknown { + try { + return createRequire(import.meta.url)(entry) + } catch (error) { + fail(`The ${label} build failed to load from ${entry}: ${String(error)}`) + } +} + +function loadBuild(entry: string, label: string): Build { + const loaded = requireEntry(entry, label) + if (typeof loaded !== 'function') { + fail(`The ${label} build at ${entry} exports no plugin factory function.`) + } + return { factory: (modules) => Reflect.apply(loaded, undefined, [modules]), label } +} + +function isPluginModule(value: unknown): value is PluginModule { + return ( + typeof value === 'object' && + value !== null && + 'create' in value && + typeof value.create === 'function' + ) +} + +interface ParsedCode { + readonly markers: ReadonlyMap + readonly text: string +} + +function parseMarkers(code: string): ParsedCode { + const markers = new Map() + let text = '' + let last = 0 + for (const match of code.matchAll(MARKER_PATTERN)) { + text += code.slice(last, match.index) + markers.set(match[1] ?? '', text.length) + last = match.index + match[0].length + } + return { markers, text: text + code.slice(last) } +} + +/** One build's language service over one in-memory file, with the plugin applied as tsserver applies it. */ +function createSession(build: Build, compareCase: CompareCase, initialText: string) { + const fileName = `${VIRTUAL_DIRECTORY}/case.${compareCase.ext ?? 'tsx'}` + const logs: string[] = [] + let text = initialText + let version = 1 + const host: ts.LanguageServiceHost = { + fileExists: (name) => name === fileName, + getCompilationSettings: () => ({ + allowJs: true, + jsx: ts.JsxEmit.Preserve, + noLib: true, + target: ts.ScriptTarget.ES2022, + }), + getCurrentDirectory: () => VIRTUAL_DIRECTORY, + getDefaultLibFileName: () => 'lib.d.ts', + getScriptFileNames: () => [fileName], + getScriptSnapshot: (name) => + name === fileName ? ts.ScriptSnapshot.fromString(text) : undefined, + getScriptVersion: () => String(version), + readFile: (name) => (name === fileName ? text : undefined), + } + const languageService = ts.createLanguageService(host) + const logger = { info: (message: string) => logs.push(message), loggingEnabled: () => true } + const pluginModule = build.factory({ typescript: ts }) + if (!isPluginModule(pluginModule)) { + fail(`The ${build.label} plugin factory returned no module with a create function.`) + } + const sourceFile = () => + languageService.getProgram()?.getSourceFile(fileName) ?? + fail(`The ${build.label} language service has no source file for ${fileName}.`) + const plugged = pluginModule.create({ + config: { name: PACKAGE_NAME, ...compareCase.config }, + languageService, + languageServiceHost: host, + project: { + getCompilerOptions: () => host.getCompilationSettings(), + getCurrentDirectory: () => VIRTUAL_DIRECTORY, + getLanguageService: () => languageService, + getScriptInfo: () => ({ + lineOffsetToPosition: (line: number, offset: number) => + sourceFile().getPositionOfLineAndCharacter(line - 1, offset - 1), + positionToLineOffset: (position: number) => { + const { character, line } = sourceFile().getLineAndCharacterOfPosition(position) + return { line: line + 1, offset: character + 1 } + }, + }), + projectService: { logger }, + }, + serverHost: {}, + }) + return { + change(start: number, end: number, insert: string) { + text = text.slice(0, start) + insert + text.slice(end) + version++ + }, + configure(configuration: unknown) { + pluginModule.onConfigurationChanged?.(configuration) + }, + fileName, + logs, + service: plugged, + text: () => text, + } +} + +type Session = ReturnType + +/** The line starts of the last text described, so describing many spans in a large file stays linear. */ +let cachedLineStartsText: string | undefined +let cachedLineStarts: readonly number[] = [] + +function lineStartsOf(text: string): readonly number[] { + if (text !== cachedLineStartsText) { + const starts = [0] + for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) { + starts.push(index + 1) + } + cachedLineStartsText = text + cachedLineStarts = starts + } + return cachedLineStarts +} + +/** `line:column "text"` for a span, both 1-based, with the spanned text quoted. */ +function describeSpan(session: Session, start: number, length: number): string { + const text = session.text() + const lineStarts = lineStartsOf(text) + let low = 0 + let high = lineStarts.length - 1 + while (low < high) { + const middle = (low + high + 1) >> 1 + if ((lineStarts[middle] ?? 0) <= start) { + low = middle + } else { + high = middle - 1 + } + } + const column = start - (lineStarts[low] ?? 0) + const spanned = text.slice(start, start + Math.min(length, MAX_QUOTED_LENGTH)) + const ellipsis = length > MAX_QUOTED_LENGTH ? '...' : '' + return `${low + 1}:${column + 1} ${JSON.stringify(spanned)}${ellipsis}` +} + +function pluginDiagnostics(session: Session): ts.Diagnostic[] { + return session.service + .getSemanticDiagnostics(session.fileName) + .filter((diagnostic) => diagnostic.source === PLUGIN_SOURCE) +} + +function flatten(message: string | ts.DiagnosticMessageChain): string { + return ts.flattenDiagnosticMessageText(message, ' ') +} + +function markerOffset( + markers: ReadonlyMap, + name: string, + caseName: string, +): number { + return ( + markers.get(name) ?? fail(`Case "${caseName}" uses the marker ⟨${name}⟩, which its code lacks.`) + ) +} + +/** Runs one operation and returns its result as comparable lines. */ +function runOperation( + session: Session, + operation: CompareOperation, + markers: ReadonlyMap, + caseName: string, +): string[] { + switch (operation.op) { + case 'diag': + return pluginDiagnostics(session).map( + (diagnostic) => + `${describeSpan(session, diagnostic.start ?? 0, diagnostic.length ?? 0)} ${flatten(diagnostic.messageText)}`, + ) + case 'comp': { + const position = markerOffset(markers, operation.at, caseName) + const info = session.service.getCompletionsAtPosition(session.fileName, position, {}) + const entries = (info?.entries ?? []).map((entry) => + entry.replacementSpan + ? `${entry.name} replacing ${JSON.stringify(session.text().slice(entry.replacementSpan.start, entry.replacementSpan.start + entry.replacementSpan.length))}` + : entry.name, + ) + return [`${entries.length} entries`, ...entries] + } + case 'hover': { + const position = markerOffset(markers, operation.at, caseName) + const info = session.service.getQuickInfoAtPosition(session.fileName, position) + if (!info) { + return ['no hover'] + } + const documentation = (info.documentation ?? []).map((part) => part.text).join('') + return [ + `${describeSpan(session, info.textSpan.start, info.textSpan.length)} ${JSON.stringify(documentation.slice(0, 120))}`, + ] + } + case 'fold': + return session.service + .getOutliningSpans(session.fileName) + .map((span) => describeSpan(session, span.textSpan.start, span.textSpan.length)) + case 'fix': + return pluginDiagnostics(session).flatMap((diagnostic) => { + const start = diagnostic.start ?? 0 + const end = start + (diagnostic.length ?? 0) + const fixes = session.service.getCodeFixesAtPosition( + session.fileName, + start, + end, + [diagnostic.code], + {}, + {}, + ) + return fixes.length === 0 + ? [`no fix for ${flatten(diagnostic.messageText)}`] + : fixes.map((fix) => `${fix.description} => ${applyFix(session.text(), fix)}`) + }) + case 'configure': + session.configure(operation.configuration) + return ['configured'] + case 'change': { + const start = markerOffset(markers, operation.from, caseName) + const end = markerOffset(markers, operation.to ?? operation.from, caseName) + session.change(start, end, operation.text) + return ['changed'] + } + } +} + +/** The source line holding a fix's first edit, after every edit of the fix is applied. */ +function applyFix(text: string, fix: ts.CodeFixAction): string { + const changes = fix.changes + .flatMap((change) => change.textChanges) + .sort((left, right) => right.span.start - left.span.start) + let result = text + for (const change of changes) { + result = + result.slice(0, change.span.start) + + change.newText + + result.slice(change.span.start + change.span.length) + } + const first = changes[changes.length - 1]?.span.start ?? 0 + const lineStart = result.lastIndexOf('\n', first - 1) + 1 + const lineEnd = result.indexOf('\n', first) + return JSON.stringify(result.slice(lineStart, lineEnd === -1 ? undefined : lineEnd)) +} + +interface OperationResult { + readonly label: string + readonly lines: readonly string[] + readonly operation: CompareOperation +} + +const DEFAULT_OPERATIONS: readonly CompareOperation[] = [{ op: 'diag' }] + +function operationLabel(operation: CompareOperation): string { + return operation.op + ('at' in operation ? ` at ⟨${operation.at}⟩` : '') +} + +/** Runs every operation of one case, reporting each result as it completes. */ +function runCase( + build: Build, + compareCase: CompareCase, + reportResult: (result: OperationResult) => void, +): void { + const parsed = parseMarkers(compareCase.code) + const session = createSession(build, compareCase, parsed.text) + for (const operation of compareCase.ops ?? DEFAULT_OPERATIONS) { + reportResult({ + label: operationLabel(operation), + lines: runRecordingThrows(() => + runOperation(session, operation, parsed.markers, compareCase.name), + ), + operation, + }) + } +} + +/** + * An exception a build lets escape is a result to compare (tsserver fails the whole response for + * the file on one), recorded by its first line; a case-file mistake still fails the run. + */ +function runRecordingThrows(run: () => string[]): string[] { + try { + return run() + } catch (error) { + if (error instanceof CompareReleaseError) { + throw error + } + const message = error instanceof Error ? error.message : String(error) + return [`threw ${message.split('\n')[0] ?? ''}`] + } +} + +function checkControl(build: Build): void { + const session = createSession(build, CONTROL_CASE, CONTROL_CASE.code) + const messages = pluginDiagnostics(session).map((diagnostic) => flatten(diagnostic.messageText)) + if (messages.length !== 1 || messages[0] !== CONTROL_MESSAGE) { + fail( + `The ${build.label} build did not report "${CONTROL_MESSAGE}" for the positive-control template ` + + `(received ${JSON.stringify(messages)}), so its results would mean nothing. Plugin log:\n` + + session.logs.join('\n'), + ) + } +} + +const OPERATION_SHAPES = + 'diag, fix, fold, { op: "comp", at, show? } with a whole-number show, { op: "hover", at }, ' + + '{ op: "configure", configuration }, and { op: "change", from, to?, text }' + +function isOperation(value: unknown): value is CompareOperation { + if (typeof value !== 'object' || value === null || !('op' in value)) { + return false + } + switch (value.op) { + case 'diag': + case 'fix': + case 'fold': + return true + case 'comp': + return ( + 'at' in value && + typeof value.at === 'string' && + (!('show' in value) || value.show === undefined || Number.isInteger(value.show)) + ) + case 'hover': + return 'at' in value && typeof value.at === 'string' + case 'configure': + return 'configuration' in value + case 'change': + return ( + 'from' in value && + typeof value.from === 'string' && + 'text' in value && + typeof value.text === 'string' && + (!('to' in value) || value.to === undefined || typeof value.to === 'string') + ) + default: + return false + } +} + +function isCompareCase(value: unknown): value is CompareCase { + return ( + typeof value === 'object' && + value !== null && + 'name' in value && + typeof value.name === 'string' && + 'code' in value && + typeof value.code === 'string' && + (!('ops' in value) || + value.ops === undefined || + (Array.isArray(value.ops) && value.ops.every(isOperation))) + ) +} + +async function loadCases(modulePath: string): Promise { + const loaded: unknown = await import(pathToFileURL(modulePath).href) + const cases = + typeof loaded === 'object' && loaded !== null && 'default' in loaded + ? loaded.default + : undefined + if (!Array.isArray(cases)) { + fail(`${modulePath} has no default export holding an array of cases.`) + } + return cases.map((value: unknown, index) => + isCompareCase(value) + ? value + : fail( + `${modulePath}, case ${index}: a case needs a string name and code, and ops as a list ` + + `of these operations: ${OPERATION_SHAPES}.`, + ), + ) +} + +/** The first `limit` items, plus a count of the rest when any are left out. */ +function truncate(items: readonly string[], limit: number): string[] { + return items.length > limit + ? [...items.slice(0, limit), `... ${items.length - limit} more`] + : [...items] +} + +/** + * Lines shown for one operation's result. Only a completion list is cut, to its `show` count + * (entries after the leading count line); every other result is shown whole. + */ +function excerpt(operation: CompareOperation, lines: readonly string[]): string[] { + if (lines.length === 0) { + return ['(none)'] + } + if (operation.op !== 'comp') { + return [...lines] + } + const [count = '', ...entries] = lines + return [count, ...truncate(entries, operation.show ?? DEFAULT_COMPLETION_SHOW)] +} + +/** One case's comparison: whether the builds differ, and the lines to print (none when neither applies). */ +function formatCase( + compareCase: CompareCase, + working: readonly OperationResult[], + release: readonly OperationResult[], + releaseLabel: string, + showAll: boolean, +): { differs: boolean; lines: string[] } { + const output: string[] = [] + let differs = false + working.forEach((result, index) => { + const other = release[index]?.lines ?? [] + const same = JSON.stringify(result.lines) === JSON.stringify(other) + differs ||= !same + if (same && !showAll) { + return + } + const { operation } = result + output.push(` ${result.label}${same ? ' (same)' : ''}`) + output.push(...excerpt(operation, result.lines).map((line) => ` working: ${line}`)) + if (!same) { + output.push(...excerpt(operation, other).map((line) => ` ${releaseLabel}: ${line}`)) + if (operation.op === 'comp') { + const limit = operation.show ?? DEFAULT_COMPLETION_SHOW + const added = result.lines.slice(1).filter((line) => !other.includes(line)) + const removed = other.slice(1).filter((line) => !result.lines.includes(line)) + output.push(` only in working: ${truncate(added, limit).join(', ') || '(none)'}`) + output.push( + ` only in ${releaseLabel}: ${truncate(removed, limit).join(', ') || '(none)'}`, + ) + } + } + }) + return { + differs, + lines: + differs || showAll ? [`== ${compareCase.name}${differs ? '' : ' (same)'}`, ...output] : [], + } +} + +/** + * A default case takes well under a second in either build, so a stop means that build regressed + * on that case (a loop that never ends, a superlinear cost, or unbounded retention). + */ +const CASE_DEADLINE_MS = 60_000 +const CASE_HEAP_CAP_MB = 2048 + +/** One build's work in a worker: its positive control when `compareCase` is absent, else one case. */ +interface BuildTask { + readonly compareCase?: CompareCase + readonly entry: string + readonly label: string +} + +function runBuildTask(task: BuildTask, reportResult: (result: OperationResult) => void): null { + const build = loadBuild(task.entry, task.label) + if (task.compareCase === undefined) { + checkControl(build) + } else { + runCase(build, task.compareCase, reportResult) + } + return null +} + +/** + * One build's worker, reused across cases so each pays the module load once, and replaced after a + * case stops it. A stopped case keeps the results of the operations it finished; the operation it + * stopped in reads as the stop, and the rest as not run. + */ +class BuildRunner { + private readonly build: Omit + private worker: BoundedWorker | undefined + + constructor(build: Omit) { + this.build = build + } + + async run(compareCase?: CompareCase): Promise { + this.worker ??= new BoundedWorker({ entry: new URL(import.meta.url), heapMb: CASE_HEAP_CAP_MB }) + const results: OperationResult[] = [] + const outcome = await this.worker.run( + { ...this.build, compareCase }, + { deadlineMs: CASE_DEADLINE_MS, onProgress: (result) => results.push(result) }, + ) + if (outcome.kind === 'done') { + return results + } + await this.close() + const subject = + compareCase === undefined ? 'the positive control' : `case "${compareCase.name}"` + if (outcome.kind === 'crashed') { + const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error) + fail(`The ${this.build.label} build's worker failed on ${subject}: ${message}`) + } + const stop = + outcome.kind === 'deadline' + ? `stopped at the ${formatSeconds(CASE_DEADLINE_MS)} wall-clock deadline` + : `stopped at the ${CASE_HEAP_CAP_MB}MB heap cap` + if (compareCase === undefined) { + fail( + `The ${this.build.label} build ${stop} on the positive control, a two-line template, so ` + + 'it is broken (a loop that never ends or unbounded retention) and its results would mean ' + + 'nothing.', + ) + } + return (compareCase.ops ?? DEFAULT_OPERATIONS).map( + (operation, index): OperationResult => + results[index] ?? { + label: operationLabel(operation), + lines: [index === results.length ? stop : 'not run: an earlier operation stopped'], + operation, + }, + ) + } + + async close(): Promise { + const worker = this.worker + this.worker = undefined + await worker?.close() + } +} + +async function compareCases( + working: BuildRunner, + release: BuildRunner, + options: ReturnType, +): Promise { + const { caseModules, filter, showAll, version } = options + await Promise.all([working.run(), release.run()]) + + let total = 0 + let differing = 0 + for (const modulePath of caseModules) { + const cases = await loadCases(modulePath) + for (const compareCase of cases) { + if (filter && !filter.test(compareCase.name)) { + continue + } + total++ + const [workingResults, releaseResults] = await Promise.all([ + working.run(compareCase), + release.run(compareCase), + ]) + const { differs, lines } = formatCase( + compareCase, + workingResults, + releaseResults, + version, + showAll, + ) + if (differs) { + differing++ + } + if (lines.length > 0) { + console.log(lines.join('\n')) + } + } + } + console.log( + `compare-release: ${total} cases, ${differing} differ from ${version}, ${total - differing} match ` + + `(${caseModules.map((modulePath) => path.basename(modulePath)).join(', ')})`, + ) +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)) + checkWorkingBuild() + const working = new BuildRunner({ entry: WORKING_ENTRY, label: 'working' }) + const release = new BuildRunner({ entry: ensureRelease(options.version), label: options.version }) + try { + await compareCases(working, release, options) + } finally { + await Promise.all([working.close(), release.close()]) + } +} + +if (isMainThread) { + try { + await main() + } catch (error) { + console.error(error instanceof CompareReleaseError ? error.message : error) + process.exitCode = 1 + } +} else { + serveBounded(runBuildTask) +} diff --git a/scripts/kill-group.ts b/scripts/kill-group.ts new file mode 100644 index 0000000..07408dd --- /dev/null +++ b/scripts/kill-group.ts @@ -0,0 +1,30 @@ +/** + * Signals a POSIX process group, in its own module so with-deadline.test.ts can import it directly + * without triggering with-deadline.ts's own `main()`, which runs as a side effect of that file being + * loaded. + */ +import process from 'node:process' + +/** Sends `signal` to the process group led by `pid` (`process.kill(-pid, signal)`), the SIGINT/SIGTERM handler and the deadline timer in with-deadline.ts. */ +export function killGroup(pid: number | undefined, signal: NodeJS.Signals): void { + if (pid === undefined) return + try { + process.kill(-pid, signal) + } catch (error) { + const isEsrch = error instanceof Error && 'code' in error && error.code === 'ESRCH' + if (isEsrch) { + /** The group already exited: a benign race between the deadline timer and a fast exit. */ + return + } + /** + * Any other error (EPERM, for example) is unexpected, but this runs inside the deadline timer + * and the SIGINT/SIGTERM handler, both of which must finish the shutdown they are in the middle + * of: an uncaught throw here would crash the wrapper mid-forward instead, leaving the child + * process group signaled but the wrapper itself dead before it could report anything or forward + * the next signal. + */ + console.error( + `with-deadline: failed to send ${signal} to process group ${pid}: ${String(error)}`, + ) + } +} diff --git a/scripts/test-package-api-types.ts b/scripts/test-package-api-types.ts new file mode 100644 index 0000000..27d708e --- /dev/null +++ b/scripts/test-package-api-types.ts @@ -0,0 +1,230 @@ +/** + * Runs every consumer type gate: each test/package-api/tsconfig.consumers..json, compiled + * by the TypeScript its "typescript/*" paths entry points at (node_modules//*, a workspace + * alias declared in test/e2e/package.json). Adding a TypeScript version means adding one tsconfig. + * + * - tsconfig.consumers.json is the shared base the version gates extend, not a gate itself. + * - A gate whose moduleResolution (its own or an extended one) is node10 first gets the + * repository self-link that resolution needs (ensureNode10SelfLink, below). + * - Fails before any gate runs when lib/ is missing, when a gate names no alias, or when an + * alias is not installed: without the alias, TypeScript silently falls back to the hoisted + * "typescript" package and checks the wrong version while still reporting success. + * - Blind spot: reads each tsconfig as plain JSON, so a comment in one fails the run by name. + * + * Usage: node --experimental-strip-types scripts/test-package-api-types.ts [gate ...] + */ +import { spawn } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + symlinkSync, + unlinkSync, +} from 'node:fs' +import type { Stats } from 'node:fs' +import path from 'node:path' + +interface CompilerOptions { + moduleResolution?: string + paths?: Record +} + +interface TsConfig { + compilerOptions?: CompilerOptions + extends?: string +} + +interface Gate { + alias: string + file: string + name: string + node10: boolean + tsc: string + version: string +} + +interface GateResult { + gate: Gate + ok: boolean + output: string +} + +const REPOSITORY_ROOT = realpathSync(path.resolve(import.meta.dirname, '..')) +const GATE_DIRECTORY = path.join(REPOSITORY_ROOT, 'test', 'package-api') +const GATE_FILE = /^tsconfig\.consumers\.(.+)\.json$/ +const ALIAS_TARGET = /(?:^|\/)node_modules\/([^/]+)\/\*$/ + +function fail(message: string): never { + console.error(message) + process.exit(1) +} + +function relative(file: string): string { + return path.relative(REPOSITORY_ROOT, file) +} + +function readJson(file: string): T { + try { + return JSON.parse(readFileSync(file, 'utf8')) as T + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + return fail(`Could not read ${relative(file)} as JSON: ${reason}`) + } +} + +/** The compiler options a tsconfig sets, merged over the ones it extends. */ +function resolvedOptions(file: string): CompilerOptions { + const config = readJson(file) + const inherited = config.extends + ? resolvedOptions(path.resolve(path.dirname(file), config.extends)) + : {} + return { ...inherited, ...config.compilerOptions } +} + +function describeGate(name: string): Gate { + const file = path.join(GATE_DIRECTORY, `tsconfig.consumers.${name}.json`) + const options = resolvedOptions(file) + const target = options.paths?.['typescript/*']?.[0] ?? '' + const alias = ALIAS_TARGET.exec(target)?.[1] + if (alias === undefined) { + fail( + `Gate ${name} (${relative(file)}) needs a "typescript/*" paths entry ` + + 'pointing at node_modules//*, the TypeScript version it checks against.', + ) + } + + const aliasDirectory = path.join(REPOSITORY_ROOT, 'node_modules', alias) + const tsc = path.join(aliasDirectory, 'bin', 'tsc') + if (!existsSync(tsc)) { + fail( + `Gate ${name} checks against node_modules/${alias}, which is not installed. Run ` + + '"corepack yarn install --immutable" to restore the alias declared in test/e2e/package.json.', + ) + } + + const { version } = readJson<{ version: string }>(path.join(aliasDirectory, 'package.json')) + return { + alias, + file, + name, + node10: ['node', 'node10'].includes(String(options.moduleResolution).toLowerCase()), + tsc, + version, + } +} + +function readLinkEntry(linkPath: string): Stats | undefined { + try { + return lstatSync(linkPath) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + } +} + +function resolveLinkTarget(linkPath: string): string | undefined { + try { + return realpathSync(linkPath) + } catch { + return undefined + } +} + +/** + * moduleResolution: node10 never consults "exports" or the self-reference feature that lets the + * other consumer files import "@styled/typescript-styled-plugin" without a real node_modules + * entry (docs/architecture.md, "Public surface"); it walks node_modules directories the way an + * installed dependency would be found. test/e2e gets this for free because Yarn links its + * "workspace:*" dependency there; test/package-api is not a workspace, so this keeps the + * equivalent link at the repository root, matching what an install of this package into itself + * would produce. + * + * - Missing: creates the link. + * - A link resolving to this repository root: leaves it alone. + * - A link resolving anywhere else, or dangling: re-points it here and says so. A second checkout + * or scratch copy that shares this node_modules through a symlink re-points the shared link at + * itself, and the node10 gate would then silently type-check that copy's lib/ instead. + * - Anything that is not a link (a real directory or file): fails, since replacing it could + * delete real content. + */ +function ensureNode10SelfLink(): void { + const scopeDirectory = path.join(REPOSITORY_ROOT, 'node_modules', '@styled') + const linkPath = path.join(scopeDirectory, 'typescript-styled-plugin') + const entry = readLinkEntry(linkPath) + + if (entry === undefined) { + mkdirSync(scopeDirectory, { recursive: true }) + symlinkSync(REPOSITORY_ROOT, linkPath, 'dir') + console.log(`Created ${relative(linkPath)} -> repository root for node10 resolution.`) + return + } + + if (!entry.isSymbolicLink()) { + fail( + `${linkPath} is a real ${entry.isDirectory() ? 'directory' : 'file'}, not a link to this ` + + 'repository, so the node10 type gate would check its contents instead of this ' + + "repository's lib/. Move it aside or delete it, then rerun; this script recreates the link.", + ) + } + + const target = resolveLinkTarget(linkPath) + if (target !== REPOSITORY_ROOT) { + unlinkSync(linkPath) + symlinkSync(REPOSITORY_ROOT, linkPath, 'dir') + console.log( + `Re-pointed ${relative(linkPath)} from ${target ?? 'a missing target'} to this repository ` + + `root (${REPOSITORY_ROOT}) for node10 resolution.`, + ) + } +} + +function runGate(gate: Gate): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [gate.tsc, '--noEmit', '-p', gate.file], { + cwd: REPOSITORY_ROOT, + }) + let output = '' + child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString())) + child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString())) + child.on('error', (error) => resolve({ gate, ok: false, output: error.message })) + child.on('close', (code) => resolve({ gate, ok: code === 0, output })) + }) +} + +if (!existsSync(path.join(REPOSITORY_ROOT, 'lib', 'index.d.cts'))) { + fail( + 'lib/ is missing, and the consumer gates type-check its declarations. Run "corepack yarn compile" first.', + ) +} + +const available = readdirSync(GATE_DIRECTORY) + .map((file) => GATE_FILE.exec(file)?.[1]) + .filter((name) => name !== undefined) + .sort() +const requested = process.argv.slice(2) +const unknown = requested.filter((name) => !available.includes(name)) +if (unknown.length > 0) { + fail(`Unknown gate ${unknown.join(', ')}. Gates: ${available.join(', ')}.`) +} + +const gates = (requested.length > 0 ? requested : available).map(describeGate) +if (gates.some((gate) => gate.node10)) { + ensureNode10SelfLink() +} + +const results = await Promise.all(gates.map(runGate)) +for (const { gate, ok, output } of results) { + console.log(`${ok ? 'ok ' : 'FAIL'} consumer gate ${gate.name} (${gate.alias} ${gate.version})`) + if (!ok) { + console.log(output.trimEnd().replace(/^/gm, ' ')) + } +} + +if (results.some((result) => !result.ok)) { + process.exitCode = 1 +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..999bb51 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + // A dedicated project rather than an entry in the root tsconfig.json's "include": these files run + // directly with `node --experimental-strip-types` as native ES modules ("type": "module" in + // package.json), which needs "module"/"moduleResolution": "nodenext" for `import.meta` and for + // resolving the "./changelog.cjs" import specifier against changelog.d.cts (Node's own extension + // convention: a ".cjs" import is described by a ".d.cts" declaration file, not ".d.ts"). The root + // tsconfig.json instead targets "module": "commonjs" for tsdown's dual-output build of src/. + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["."] +} diff --git a/scripts/with-deadline.ts b/scripts/with-deadline.ts new file mode 100644 index 0000000..20a7eb3 --- /dev/null +++ b/scripts/with-deadline.ts @@ -0,0 +1,116 @@ +/** + * Runs a command in its own POSIX process group and kills the whole group if the command has not + * finished within a wall-clock deadline, so a child stuck in a synchronous loop, which a timer + * running on the same blocked thread (Vitest's `testTimeout`, for example) can never interrupt, + * fails this wrapper loudly instead of hanging the machine. + * + * Usage: node --experimental-strip-types scripts/with-deadline.ts -- [args...] + * + * - The deadline can be overridden with the WITH_DEADLINE_MS environment variable: a shorter one to + * red/green test this wrapper, a longer one to keep a debugger attached past the default. + * - `` starts with `detached: true`, which on POSIX makes it the leader of a new process + * group; killing that group with `process.kill(-pid, signal)` reaches every descendant that never + * called setsid itself, including Vitest's forked test workers and any tsserver process one of + * them spawns, not only the immediate child. This is a POSIX mechanism; the project has no + * Windows CI job or documented Windows support to extend it to. + * - stdio is inherited, so the child's stdout and stderr reach the terminal or CI log unbuffered + * and byte-for-byte, and a run that finishes before the deadline passes its exit code straight + * through, unchanged. + * - SIGINT and SIGTERM received by this wrapper (Ctrl-C, a CI job cancellation) are forwarded to + * the child's process group, then re-raised on this process once the child has actually exited, + * instead of only killing the wrapper and leaving the child tree running as an orphan. + * - A run that hits the deadline is killed with SIGKILL: the failure mode this guards against, a + * synchronous loop that never returns to the event loop, cannot act on SIGTERM either. + */ +import { spawn } from 'node:child_process' +import process from 'node:process' + +import { formatSeconds } from './bounded-worker.ts' +import { killGroup } from './kill-group.ts' + +const DEADLINE_OVERRIDE_ENV_VAR = 'WITH_DEADLINE_MS' + +function usageError(message: string): never { + console.error(message) + console.error( + 'Usage: node --experimental-strip-types scripts/with-deadline.ts -- [args...]', + ) + process.exit(1) +} + +function parseDeadlineMs(defaultArg: string): number { + const override = process.env[DEADLINE_OVERRIDE_ENV_VAR] + const source = override ?? defaultArg + const value = Number(source) + if (!Number.isFinite(value) || value <= 0) { + usageError( + override === undefined + ? `Invalid deadline "${defaultArg}": expected a positive number of milliseconds.` + : `Invalid ${DEADLINE_OVERRIDE_ENV_VAR}="${override}": expected a positive number of milliseconds.`, + ) + } + return value +} + +function main(): void { + const args = process.argv.slice(2) + if (args.length < 3 || args[1] !== '--') { + usageError('Expected " -- [args...]".') + } + const deadlineMs = parseDeadlineMs(args[0]) + const [command, ...commandArgs] = args.slice(2) + const commandLine = [command, ...commandArgs].join(' ') + + const startedAt = performance.now() + const child = spawn(command, commandArgs, { detached: true, stdio: 'inherit' }) + + let deadlineHit = false + const timer = setTimeout(() => { + deadlineHit = true + killGroup(child.pid, 'SIGKILL') + }, deadlineMs) + + const forwardSignal = (signal: NodeJS.Signals) => killGroup(child.pid, signal) + process.on('SIGINT', forwardSignal) + process.on('SIGTERM', forwardSignal) + + child.on('error', (error) => { + clearTimeout(timer) + console.error(`Failed to start "${commandLine}": ${String(error)}`) + process.exitCode = 1 + }) + + child.on('exit', (code, signal) => { + clearTimeout(timer) + process.off('SIGINT', forwardSignal) + process.off('SIGTERM', forwardSignal) + + if (deadlineHit) { + const ranFor = formatSeconds(performance.now() - startedAt) + const likelyCause = + command === 'vitest' + ? ' Likely cause: a test stuck in a synchronous loop, which testTimeout cannot interrupt.' + + ' Rerun with --reporter=verbose to see which test last started.' + : '' + console.error( + `"${commandLine}" did not finish within its ${formatSeconds(deadlineMs)} deadline ` + + `(ran ${ranFor}) and was killed.${likelyCause}`, + ) + process.exitCode = 1 + return + } + + if (signal !== null) { + /** + * Re-raise the same signal on this process, once the child has actually exited, so the + * caller sees the same signal death (the usual 128+n exit convention) as an unwrapped run. + */ + process.kill(process.pid, signal) + return + } + + process.exitCode = code ?? 1 + }) +} + +main() diff --git a/test/e2e/tsserver-fixture/timeouts.ts b/test/e2e/tsserver-fixture/timeouts.ts new file mode 100644 index 0000000..fae6bf3 --- /dev/null +++ b/test/e2e/tsserver-fixture/timeouts.ts @@ -0,0 +1,26 @@ +/** + * Upper bound on each tsserver response and on the process exit after `close()`. A request runs + * against a real TypeScript program in a separate process, so its wall time tracks host CPU + * contention rather than the request's own work; the default leaves headroom for a busy host. + * `vitest.config.ts` derives the e2e `testTimeout` from this value, so a slow response fails with + * this harness's message naming the request instead of Vitest's generic "test timed out". + */ +const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000 + +const responseTimeoutVariable = 'TSSERVER_RESPONSE_TIMEOUT_MS' + +function parsePositiveInteger(name: string, value: string | undefined): number | undefined { + if (value === undefined) { + return undefined + } + if (!/^[1-9]\d*$/.test(value)) { + throw new Error( + `${name} is "${value}", which is not a whole number of milliseconds above zero. Set it to a value such as 60000, or unset it to use ${DEFAULT_RESPONSE_TIMEOUT_MS}.`, + ) + } + return Number(value) +} + +export const responseTimeoutMs = + parsePositiveInteger(responseTimeoutVariable, process.env[responseTimeoutVariable]) ?? + DEFAULT_RESPONSE_TIMEOUT_MS diff --git a/tsconfig.json b/tsconfig.json index ee4b765..045818d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,13 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "ES2019", - "outDir": "lib", - "rootDir": "src", - "strict": true, - "declaration": true, - "sourceMap": true, - "skipLibCheck": true - }, - "exclude": ["lib", "e2e", "node_modules", "test-workspace"] + "compilerOptions": { + "allowImportingTsExtensions": true, + "module": "commonjs", + "target": "ES2024", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src", "test/unit", "test/performance", "tsdown.config.ts"], + "exclude": ["lib", "node_modules", "test/e2e"] } diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..12550f3 --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,52 @@ +import { copyFileSync, rmSync, writeFileSync } from 'node:fs' + +import { defineConfig } from 'tsdown' + +const deps = { + dts: { + neverBundle: [/^typescript(?:\/|$)/], + }, + onlyBundle: false as const, +} + +/** + * Every output path, and every file the build:done hooks write, is specified in + * docs/architecture.md, "Public surface". + */ +export default defineConfig([ + { + cjsDefault: true, + deps, + dts: true, + entry: { index: 'src/index.ts' }, + fixedExtension: true, + format: 'cjs', + hooks: { + 'build:done': () => { + copyFileSync('lib/index.cjs', 'lib/index.js') + rmSync('lib/index.cjs') + copyFileSync('lib/index.d.cts', 'lib/index.d.ts') + writeFileSync('lib/package.json', `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`) + }, + }, + outDir: 'lib', + platform: 'node', + target: 'node14', + }, + { + deps, + dts: true, + entry: { 'esm/api': 'src/api.ts' }, + fixedExtension: true, + format: ['esm', 'cjs'], + hooks: { + 'build:done': () => { + writeFileSync('lib/api.js', "module.exports = require('./esm/api.cjs')\n") + writeFileSync('lib/api.d.ts', "export * from './esm/api.cjs'\n") + }, + }, + outDir: 'lib', + platform: 'node', + target: 'node14', + }, +]) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..663ddef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,61 @@ +import { availableParallelism } from 'node:os' + +import { defineConfig } from 'vitest/config' + +import { responseTimeoutMs } from './test/e2e/tsserver-fixture/timeouts' + +/** + * Two projects, so only e2e files run the e2e globalSetup: Vitest assigns each test file to a + * project by its `include` glob, whatever the invocation (a path, a `-t` filter, an IDE runner). + */ + +/** + * Every e2e test file runs at least one tsserver process, a full TypeScript program load. Workers + * leave one core free, as Vitest's own default does, and never exceed the host's core count. + * Concurrent tests within one file (the files that start a server per test) are capped at the + * same number, so a 4-core CI runner holds at most a few tsserver processes per worker. + */ +const e2eParallelism = Math.max(1, Math.min(8, availableParallelism() - 1)) + +/** + * Unit tests run plugin code inside each test worker, a child process in Vitest's default `forks` + * pool. The heap cap ends a worker whose test retains without bound within seconds, as a failed + * run naming the test file, instead of letting every parallel worker grow toward swap; the suite + * needs a small fraction of it (lower it with `--execArgv=--max-old-space-size=` to see). + * scripts/with-deadline.ts, which wraps this command, separately catches a test stuck in a + * synchronous loop, which Vitest's own `testTimeout` cannot interrupt. Every bound on a tool that + * runs plugin code is in docs/maintenance.md, "Running tests". + */ +const UNIT_WORKER_HEAP_CAP_MB = 1024 + +export default defineConfig({ + test: { + projects: [ + { + test: { + environment: 'node', + execArgv: [`--max-old-space-size=${UNIT_WORKER_HEAP_CAP_MB}`], + globals: true, + include: ['test/unit/**/*.test.ts'], + name: 'unit', + }, + }, + { + test: { + environment: 'node', + globals: true, + globalSetup: [ + 'test/e2e/tsserver-fixture/check-plugin-link.ts', + 'test/e2e/tsserver-fixture/clear-logs.ts', + ], + include: ['test/e2e/scenarios/**/*.test.ts'], + maxConcurrency: e2eParallelism, + maxWorkers: e2eParallelism, + name: 'e2e', + /** Above the harness's own response timeout, so its message naming the request wins. */ + testTimeout: responseTimeoutMs + 15_000, + }, + }, + ], + }, +}) diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..3c9db9f --- /dev/null +++ b/yarn.lock @@ -0,0 +1,2275 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 10 + cacheKey: 10c0 + +"@changesets/apply-release-plan@npm:^8.1.1": + version: 8.1.1 + resolution: "@changesets/apply-release-plan@npm:8.1.1" + dependencies: + "@changesets/config": "npm:^4.0.1" + "@changesets/format": "npm:^0.1.2" + "@changesets/git": "npm:^4.0.1" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + import-meta-resolve: "npm:^4.2.0" + jsonc-parser: "npm:^3.3.1" + semver: "npm:^7.8.1" + checksum: 10c0/2a1cfb9e4e3feb2c76bcec646e116ebebc81271fed21a3e77d5f170963f88347a34e5d1474f8b87258448182cfa651c047282c8b1a741613a7f7d3fefc2eb9a2 + languageName: node + linkType: hard + +"@changesets/assemble-release-plan@npm:^7.0.0": + version: 7.0.0 + resolution: "@changesets/assemble-release-plan@npm:7.0.0" + dependencies: + "@changesets/errors": "npm:^1.0.0" + "@changesets/get-dependents-graph": "npm:^3.0.0" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + semver: "npm:^7.8.1" + checksum: 10c0/2544759583889865487aec744e948c6d84206a42aa593430436ace83003fe00d3880ef623114c396cd5fd883eccb1e03fc4a719ba5c33038a8556df8fca4b9b7 + languageName: node + linkType: hard + +"@changesets/changelog-git@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/changelog-git@npm:1.0.0" + dependencies: + "@changesets/types": "npm:^7.0.0" + checksum: 10c0/f0f0ab31e6e6ac35ecdae46a521d4c84aab77bbb035898e364d8f9082391b2b8064ba33d9f97d4f1526195f8278f41910c72382b12fcc0092d5bb9ff3ae05310 + languageName: node + linkType: hard + +"@changesets/cli@npm:^3.0.3": + version: 3.0.3 + resolution: "@changesets/cli@npm:3.0.3" + dependencies: + "@changesets/apply-release-plan": "npm:^8.1.1" + "@changesets/assemble-release-plan": "npm:^7.0.0" + "@changesets/changelog-git": "npm:^1.0.0" + "@changesets/config": "npm:^4.0.1" + "@changesets/errors": "npm:^1.0.0" + "@changesets/get-dependents-graph": "npm:^3.0.0" + "@changesets/git": "npm:^4.0.1" + "@changesets/pre": "npm:^3.0.0" + "@changesets/read": "npm:^1.0.1" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@changesets/write": "npm:^1.0.1" + "@clack/prompts": "npm:^1.7.0" + "@manypkg/get-packages": "npm:^3.1.0" + "@pnpm/deps.graph-sequencer": "npm:^1100.0.1" + cac: "npm:^7.0.0" + import-meta-resolve: "npm:^4.2.0" + launch-editor: "npm:^2.14.1" + package-manager-detector: "npm:^1.6.0" + semver: "npm:^7.8.1" + tinyexec: "npm:^1.3.1" + bin: + changeset: bin.js + checksum: 10c0/238b98c590bfe33e794cf5ab47e0fa6eb46fc587da9498af49037877f1043b3e91dac65c990ebd1675115d82f2a3a2b3e7aa1b5d385369b0322a24e858eb1313 + languageName: node + linkType: hard + +"@changesets/config@npm:^4.0.1": + version: 4.0.1 + resolution: "@changesets/config@npm:4.0.1" + dependencies: + "@changesets/get-dependents-graph": "npm:^3.0.0" + "@changesets/should-skip-package": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + picomatch: "npm:^4.0.7" + checksum: 10c0/959dbbb3ab9fab0427a8990cbe7d4465d9b67042c96108209c8110b7c5ccb2fc3303b75d5c024e911d3a51ca082d13d49a5091edf52014a2b8d69e89b58188ed + languageName: node + linkType: hard + +"@changesets/errors@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/errors@npm:1.0.0" + checksum: 10c0/502439fb046b9916e8a0745374c75fa80883b0bdeea92aa2e85be07b8675dcdb50df5bd108ce66eddbda1f8863be750bf817301c9d4904c2168305df870e3f9f + languageName: node + linkType: hard + +"@changesets/format@npm:^0.1.1, @changesets/format@npm:^0.1.2": + version: 0.1.2 + resolution: "@changesets/format@npm:0.1.2" + dependencies: + package-manager-detector: "npm:^1.8.0" + tinyexec: "npm:^1.3.0" + checksum: 10c0/fdb35b885b0923c2fac48433f5d633cc1a091f10e589eb65e3cd33c6702864ea26ef41d7bb434f0c91715dafa609442b91a780e84089b9bd33b7906d65d13f39 + languageName: node + linkType: hard + +"@changesets/get-dependents-graph@npm:^3.0.0": + version: 3.0.0 + resolution: "@changesets/get-dependents-graph@npm:3.0.0" + dependencies: + "@changesets/types": "npm:^7.0.0" + semver: "npm:^7.8.1" + checksum: 10c0/ab675098db0b92f61115952fb793620bb5b76e502bad5f7f63bcc85a50e5515f70e08957be87381a58141ab2078bf94dd92df86efa55e73227e7604ba6a3968b + languageName: node + linkType: hard + +"@changesets/git@npm:^4.0.1": + version: 4.0.1 + resolution: "@changesets/git@npm:4.0.1" + dependencies: + "@changesets/errors": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + picomatch: "npm:^4.0.4" + tinyexec: "npm:^1.3.0" + checksum: 10c0/b548ecfafb238ff477a793e4ee91c1fe818274b9ea5b6d5a9d619f75a915ad27831f2accf2edce77da57f918bf1009b59cca2c5aaa7288db4b266f87872c2c23 + languageName: node + linkType: hard + +"@changesets/parse@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/parse@npm:1.0.0" + dependencies: + "@changesets/types": "npm:^7.0.0" + yaml: "npm:^2.9.0" + checksum: 10c0/76e93b099015420dfc08f3e3a9321ea2512659d8946d1ed86de64899b52d37faa87173cc80ead5d313ffce2f829ce380a0188c0a1cccaad48350e7c6a83720d0 + languageName: node + linkType: hard + +"@changesets/pre@npm:^3.0.0": + version: 3.0.0 + resolution: "@changesets/pre@npm:3.0.0" + dependencies: + "@changesets/errors": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + "@manypkg/get-packages": "npm:^3.1.0" + checksum: 10c0/56e49668f25cabf50dcab12b6ae20fd7dbeba628390149d38a9075b05c41b954d4fbcebe7535878087c5f0b707ce96b90b727286850c71d6d321477bd1a1c18e + languageName: node + linkType: hard + +"@changesets/read@npm:^1.0.1": + version: 1.0.1 + resolution: "@changesets/read@npm:1.0.1" + dependencies: + "@changesets/git": "npm:^4.0.1" + "@changesets/parse": "npm:^1.0.0" + "@changesets/types": "npm:^7.0.0" + checksum: 10c0/125de7132b71e9c184f35ec0ad4d23b9accb0aa04d178c835ef575fbf12afbed2fd2d975e987ebb70c007c9a08dc97a267123ba515a4c2f4f070279ad8193f46 + languageName: node + linkType: hard + +"@changesets/should-skip-package@npm:^1.0.0": + version: 1.0.0 + resolution: "@changesets/should-skip-package@npm:1.0.0" + dependencies: + "@changesets/types": "npm:^7.0.0" + checksum: 10c0/9beb51e7a6a6d678fbfe60efca4646d7daf117ffb90d3dbe3e8b07806f87383cf2a49b0e15b5fe2b827b69f61c0cf7cb77c4bf27b403c2ce835c5ba7990ed0e5 + languageName: node + linkType: hard + +"@changesets/types@npm:^7.0.0": + version: 7.0.0 + resolution: "@changesets/types@npm:7.0.0" + checksum: 10c0/494e09ff94968c1c81521f5ac8c6ab60a1e30d7d0cd9ea7f83c2cbac92efba38b03e008ee6fb4592d90b05d69bad864b6ae8155827d52a51c652f5c24d36ec83 + languageName: node + linkType: hard + +"@changesets/write@npm:^1.0.1": + version: 1.0.1 + resolution: "@changesets/write@npm:1.0.1" + dependencies: + "@changesets/format": "npm:^0.1.1" + "@changesets/types": "npm:^7.0.0" + human-id: "npm:^4.2.0" + checksum: 10c0/c9cb074a8c524db1327e54dc1379ff4b50d3a4915409a3e5abe1edaf1ce45f6dafc6c8aced17a1f3eebfa162241b5fd8ea74fc751d2e24df56c2966e4169fcaf + languageName: node + linkType: hard + +"@clack/core@npm:1.5.1": + version: 1.5.1 + resolution: "@clack/core@npm:1.5.1" + dependencies: + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/6dca93ff5a34bfe8c150367519bc40a847e9172cb1a78b18c88da0507cb6ad899debe1ad5cfcb9796e439108f12f003cf06fd002e2293bc85ff500975e45ff13 + languageName: node + linkType: hard + +"@clack/prompts@npm:^1.7.0": + version: 1.8.1 + resolution: "@clack/prompts@npm:1.8.1" + dependencies: + "@clack/core": "npm:1.5.1" + fast-string-width: "npm:^3.0.2" + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/ed38748acde0cb1e7ebdac1242470c78bd90380cbdde1e5560e0828881e3c74e2676de666086cf8183c8d4d3f1fc6e489f15a004f0d9f8ee294a3aa744de2093 + languageName: node + linkType: hard + +"@emmetio/abbreviation@npm:^2.3.3": + version: 2.3.3 + resolution: "@emmetio/abbreviation@npm:2.3.3" + dependencies: + "@emmetio/scanner": "npm:^1.0.4" + checksum: 10c0/835b460706d5920a6f9a569a44b7d98e88d5530e3983af3678b44fa38b4cbdf68b5df933476d72e340779b16e7e7962ffa63142db8d2f59b1175a11c30c14635 + languageName: node + linkType: hard + +"@emmetio/css-abbreviation@npm:^2.1.8": + version: 2.1.8 + resolution: "@emmetio/css-abbreviation@npm:2.1.8" + dependencies: + "@emmetio/scanner": "npm:^1.0.4" + checksum: 10c0/b5b3b39e773185d848b634e48e1b520e6ebffd28bfd0ba34fbcf877ca77e0edb8c7bbf58230cb0621f80f579bd7fd0265f00ab5e09ac482a835897cbdb6182a6 + languageName: node + linkType: hard + +"@emmetio/scanner@npm:^1.0.4": + version: 1.0.4 + resolution: "@emmetio/scanner@npm:1.0.4" + checksum: 10c0/ae6244e563caaff0f88d7afefc33fd6cfb7cc767ce914b54d35b46002637948cfc65951dba6d6941328afa54c721c225836fafce2de40fb7643660ba09fe7372 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.6.0": + version: 1.6.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.6.0" + checksum: 10c0/b5be700e45a775f218589c3466c4ffea630582b4988657652da464e5ab8a7d18bf928ee4fd2363fb346ede4bea9c6ff0bae05a538358c4565ece6199531b5f72 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@manypkg/find-root@npm:^3.1.0": + version: 3.1.0 + resolution: "@manypkg/find-root@npm:3.1.0" + dependencies: + "@manypkg/tools": "npm:^2.1.0" + checksum: 10c0/15b3f5c5c66881af88c1c70dcec805237a2b7e1d541ca7687aade00978680024f8e77ca2f4bac1415e00377f2e78a998d842c345c288d3e69383d50280620b1c + languageName: node + linkType: hard + +"@manypkg/get-packages@npm:^3.1.0": + version: 3.1.0 + resolution: "@manypkg/get-packages@npm:3.1.0" + dependencies: + "@manypkg/find-root": "npm:^3.1.0" + "@manypkg/tools": "npm:^2.1.0" + checksum: 10c0/e8baabd85a13f4537ae22124d5e53f1523ba0653f33006da45fc8bd642e0134786a6aad9d0a7ed99282ae20a80687ef5700bd72980441627b5c96658503ad83d + languageName: node + linkType: hard + +"@manypkg/tools@npm:^2.1.0": + version: 2.1.2 + resolution: "@manypkg/tools@npm:2.1.2" + dependencies: + jju: "npm:^1.4.0" + tinyglobby: "npm:^0.2.13" + yaml: "npm:^2.9.0" + checksum: 10c0/05c8ece3b3b3ff170765ecf230f0a5408543f1ad43f8b5dc97651adff0ffed3d860dabdd1e2d1068aa247832d473c4de583791675b8f3d07568da4cab2a0ebc3 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.151.0": + version: 0.151.0 + resolution: "@oxc-project/types@npm:0.151.0" + checksum: 10c0/7dac5399345abf368449735c31c218439e6c3a2acf7b8cbc715a88b45989390208f61bea0f3eb28bee5a034a509a7f4142ae60069e6611b07c70a30867c8ca84 + languageName: node + linkType: hard + +"@oxfmt/binding-android-arm-eabi@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-android-arm-eabi@npm:0.70.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@oxfmt/binding-android-arm64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-android-arm64@npm:0.70.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-darwin-arm64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-darwin-arm64@npm:0.70.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-darwin-x64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-darwin-x64@npm:0.70.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxfmt/binding-freebsd-x64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-freebsd-x64@npm:0.70.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm-gnueabihf@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.70.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm-musleabihf@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.70.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.70.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm64-musl@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.70.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxfmt/binding-linux-ppc64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.70.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-riscv64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.70.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-riscv64-musl@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.70.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@oxfmt/binding-linux-s390x-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.70.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-x64-gnu@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.70.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-x64-musl@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-linux-x64-musl@npm:0.70.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxfmt/binding-openharmony-arm64@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-openharmony-arm64@npm:0.70.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-win32-arm64-msvc@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.70.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-win32-ia32-msvc@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.70.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@oxfmt/binding-win32-x64-msvc@npm:0.70.0": + version: 0.70.0 + resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.70.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/binding-android-arm-eabi@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-android-arm-eabi@npm:1.85.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@oxlint/binding-android-arm64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-android-arm64@npm:1.85.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-darwin-arm64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-darwin-arm64@npm:1.85.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-darwin-x64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-darwin-x64@npm:1.85.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/binding-freebsd-x64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-freebsd-x64@npm:1.85.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm-gnueabihf@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.85.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm-musleabihf@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.85.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.85.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm64-musl@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-arm64-musl@npm:1.85.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/binding-linux-ppc64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.85.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-riscv64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.85.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-riscv64-musl@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.85.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/binding-linux-s390x-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.85.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-x64-gnu@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-x64-gnu@npm:1.85.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-x64-musl@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-linux-x64-musl@npm:1.85.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/binding-openharmony-arm64@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-openharmony-arm64@npm:1.85.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-win32-arm64-msvc@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.85.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-win32-ia32-msvc@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.85.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@oxlint/binding-win32-x64-msvc@npm:1.85.0": + version: 1.85.0 + resolution: "@oxlint/binding-win32-x64-msvc@npm:1.85.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@pnpm/deps.graph-sequencer@npm:^1100.0.1": + version: 1100.0.1 + resolution: "@pnpm/deps.graph-sequencer@npm:1100.0.1" + checksum: 10c0/b38a152e1184055a358e7c522f068572e84759a88dfc56c98a349125b11d3cce72e452424c235af5cf18258a5034f88fe203c72ba57ffbe70b297afc50d08dea + languageName: node + linkType: hard + +"@quansync/fs@npm:^1.0.0": + version: 1.1.0 + resolution: "@quansync/fs@npm:1.1.0" + dependencies: + quansync: "npm:^1.0.0" + checksum: 10c0/fa7bca4eae8ec036bfe3181304bf827f10f798ac485d698e7d72fde98c5786f8685aecf2c0f8d5f7fdff8608487c9d62794ba5f4def02518d6835ecda6d8e4cc + languageName: node + linkType: hard + +"@rolldown/binding-android-arm-eabi@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-android-arm-eabi@npm:1.2.11" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-android-arm64@npm:1.2.11" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.11" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.11" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.11" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.11" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.11" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.11" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.11" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.11" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.11" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.11" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.11" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.2.11": + version: 1.2.11 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.11" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd + languageName: node + linkType: hard + +"@styled/typescript-styled-plugin@workspace:*, @styled/typescript-styled-plugin@workspace:.": + version: 0.0.0-use.local + resolution: "@styled/typescript-styled-plugin@workspace:." + dependencies: + "@changesets/cli": "npm:^3.0.3" + "@types/node": "npm:^24" + "@vscode/emmet-helper": "npm:2.11.0" + oxfmt: "npm:^0.70.0" + oxlint: "npm:^1.85.0" + tinybench: "npm:^6.2.0" + tsdown: "npm:^0.23.0" + typescript: "npm:~6.0.3" + typescript-template-language-service-decorator: "npm:^2.3.2" + vite: "npm:^8.3.1" + vitest: "npm:^5.0.2" + vscode-css-languageservice: "npm:6.3.10" + vscode-languageserver-textdocument: "npm:1.0.15" + vscode-languageserver-types: "npm:3.18.4" + languageName: unknown + linkType: soft + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + +"@types/node@npm:^24": + version: 24.19.0 + resolution: "@types/node@npm:24.19.0" + dependencies: + undici-types: "npm:>=7.24.0 <7.24.7" + checksum: 10c0/f38afca05eb323a01243aa06a4cb8e69ef3423e1115e4464a211c6556156c024bb80fadeb7e3691802a247e7e653b70bad8c528b291153e4f48fccecc6159735 + languageName: node + linkType: hard + +"@vitest/mocker@npm:5.0.2": + version: 5.0.2 + resolution: "@vitest/mocker@npm:5.0.2" + dependencies: + "@jridgewell/trace-mapping": "npm:0.3.31" + "@vitest/spy": "npm:5.0.2" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^1.2.3" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/e2d847563d47b61e9f3e5ed2d86af42b753311cd52c94ef7226398fd6f810b96ca4737855bcc4a59d78a0300859db50693d831ab29ca6334b159cc8e870f8149 + languageName: node + linkType: hard + +"@vitest/spy@npm:5.0.2": + version: 5.0.2 + resolution: "@vitest/spy@npm:5.0.2" + checksum: 10c0/4a52231cc7b5b8959bb15a700a761464051a6d0cc35a231fc92fd4f58d10a01012671a20ea37ebc79e98bdd5dc257a3c52db10d6a592944560fb69651f6f1296 + languageName: node + linkType: hard + +"@vscode/emmet-helper@npm:2.11.0": + version: 2.11.0 + resolution: "@vscode/emmet-helper@npm:2.11.0" + dependencies: + emmet: "npm:^2.4.3" + jsonc-parser: "npm:^2.3.0" + vscode-languageserver-textdocument: "npm:^1.0.1" + vscode-languageserver-types: "npm:^3.15.1" + vscode-uri: "npm:^3.0.8" + checksum: 10c0/41c743258f958ff143f5152a7c8e8e1c6f96336f4147d537ace7c81337d5f9d7f93efd6df16c80e6d9506c51df9c8deb6440765a1deae219e31cdbead3cfd841 + languageName: node + linkType: hard + +"@vscode/l10n@npm:^0.0.18": + version: 0.0.18 + resolution: "@vscode/l10n@npm:0.0.18" + checksum: 10c0/d1fc797001f7d508ab3fa91175f7a50ea98516c4e47830ff2be79163cde9279279514a167a3bad15b7ab7fc243e7808d8f32d3eb41f4a7d6721d9dfdbb38d89e + languageName: node + linkType: hard + +"@yuku-codegen/binding-android-arm64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-android-arm64@npm:0.10.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-codegen/binding-darwin-arm64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-darwin-arm64@npm:0.10.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-codegen/binding-darwin-x64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-darwin-x64@npm:0.10.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@yuku-codegen/binding-freebsd-x64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-freebsd-x64@npm:0.10.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@yuku-codegen/binding-linux-arm-gnu@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-linux-arm-gnu@npm:0.10.2" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@yuku-codegen/binding-linux-arm-musl@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-linux-arm-musl@npm:0.10.2" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@yuku-codegen/binding-linux-arm64-gnu@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-linux-arm64-gnu@npm:0.10.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@yuku-codegen/binding-linux-arm64-musl@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-linux-arm64-musl@npm:0.10.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@yuku-codegen/binding-linux-x64-gnu@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-linux-x64-gnu@npm:0.10.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@yuku-codegen/binding-linux-x64-musl@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-linux-x64-musl@npm:0.10.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@yuku-codegen/binding-win32-arm64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-win32-arm64@npm:0.10.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-codegen/binding-win32-x64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-codegen/binding-win32-x64@npm:0.10.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@yuku-parser/binding-android-arm64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-android-arm64@npm:0.10.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-parser/binding-darwin-arm64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-darwin-arm64@npm:0.10.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-parser/binding-darwin-x64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-darwin-x64@npm:0.10.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@yuku-parser/binding-freebsd-x64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-freebsd-x64@npm:0.10.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@yuku-parser/binding-linux-arm-gnu@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-linux-arm-gnu@npm:0.10.2" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@yuku-parser/binding-linux-arm-musl@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-linux-arm-musl@npm:0.10.2" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@yuku-parser/binding-linux-arm64-gnu@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-linux-arm64-gnu@npm:0.10.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@yuku-parser/binding-linux-arm64-musl@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-linux-arm64-musl@npm:0.10.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@yuku-parser/binding-linux-x64-gnu@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-linux-x64-gnu@npm:0.10.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@yuku-parser/binding-linux-x64-musl@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-linux-x64-musl@npm:0.10.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@yuku-parser/binding-win32-arm64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-win32-arm64@npm:0.10.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@yuku-parser/binding-win32-x64@npm:0.10.2": + version: 0.10.2 + resolution: "@yuku-parser/binding-win32-x64@npm:0.10.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@yuku-toolchain/types@npm:^0.10.2": + version: 0.10.2 + resolution: "@yuku-toolchain/types@npm:0.10.2" + checksum: 10c0/a56b8b1b69d84287c0ffc4ba9fb69bd306b419fb835c42057bd95d80b0c68e1a28ed95db0238393251e48ed7e93d479b1f71aa20a248cfb1e80211466f6374c0 + languageName: node + linkType: hard + +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"cac@npm:^7.0.0": + version: 7.0.0 + resolution: "cac@npm:7.0.0" + checksum: 10c0/e9da33cb9f0425546ae92a450d479276f9969a050fe64f5d6fedf058bdd87f22a370797fe1c158e07655fa9fc183749df7cb2037431e3772faa7bee9919fb763 + languageName: node + linkType: hard + +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"defu@npm:^6.1.7": + version: 6.1.7 + resolution: "defu@npm:6.1.7" + checksum: 10c0/e6635388103c8be3c574ac31302f6930e5e6eeedba32cb1b30cf993c7d9fb571aec2485446dfa23bfa63e55e66156fe109027a9695db82a50f931e91e8d4bedb + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + +"dts-resolver@npm:^3.0.0": + version: 3.0.0 + resolution: "dts-resolver@npm:3.0.0" + peerDependencies: + oxc-resolver: ">=11.0.0" + peerDependenciesMeta: + oxc-resolver: + optional: true + checksum: 10c0/ae52c4e09b43def702b02d7edbfa04798522907f22879809f8890e5c61fc762403f46a1c415c2d1a6440b01960bbaf98c72def1771b0962d3b15fec40983a4ab + languageName: node + linkType: hard + +"e2e@workspace:test/e2e": + version: 0.0.0-use.local + resolution: "e2e@workspace:test/e2e" + dependencies: + "@styled/typescript-styled-plugin": "workspace:*" + typescript: "npm:~6.0.3" + typescript-5.5: "npm:typescript@5.5.4" + typescript-5.9: "npm:typescript@5.9.3" + typescript-current: "npm:typescript@6.0.3" + typescript-legacy: "npm:typescript@4.9.5" + typescript-minimum: "npm:typescript@5.0.4" + languageName: unknown + linkType: soft + +"emmet@npm:^2.4.3": + version: 2.4.11 + resolution: "emmet@npm:2.4.11" + dependencies: + "@emmetio/abbreviation": "npm:^2.3.3" + "@emmetio/css-abbreviation": "npm:^2.1.8" + checksum: 10c0/4099d9d0d5dee766603c4ea03e1b87296bd397a0e8c6d8d5d6dcfdaad3e4581df5d48939a00eb4437dc08c83e857e231222ee037cb34ad63b1f2cce4041c6fc2 + languageName: node + linkType: hard + +"empathic@npm:^2.0.1": + version: 2.1.0 + resolution: "empathic@npm:2.1.0" + checksum: 10c0/cf13e2919d680a22140d7cd1637a01f6cf9a727f21d3a54b23489ec01c80eab548b9d832d2e167d00e124a54377660c1d6fd2b5947d905366e4d22fde7b9e082 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"es-module-lexer@npm:^2.3.2": + version: 2.3.2 + resolution: "es-module-lexer@npm:2.3.2" + checksum: 10c0/5e7389424c43478439f12f9a6aca1750f6f99afa384fc3de329f4a45f152ab156671055008adaafc74960877abf8cc338aeebf6bed8c21297b146fd6eb7a22f8 + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"expect-type@npm:^1.4.0": + version: 1.4.0 + resolution: "expect-type@npm:1.4.0" + checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fast-string-truncated-width@npm:^3.0.2": + version: 3.0.3 + resolution: "fast-string-truncated-width@npm:3.0.3" + checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981 + languageName: node + linkType: hard + +"fast-string-width@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-string-width@npm:3.0.2" + dependencies: + fast-string-truncated-width: "npm:^3.0.2" + checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7 + languageName: node + linkType: hard + +"fast-wrap-ansi@npm:^0.2.0": + version: 0.2.2 + resolution: "fast-wrap-ansi@npm:0.2.2" + dependencies: + fast-string-width: "npm:^3.0.2" + checksum: 10c0/1aa7be4f7cb86f4bdb14691cb6bcc0b8df8b3b89df142ade3ae1602332dcf6f990cd750a923cd581ca0847808cb4ec1aa5afaafa7a72f849e87a2a62c98fa370 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"get-tsconfig@npm:5.0.0-beta.6": + version: 5.0.0-beta.6 + resolution: "get-tsconfig@npm:5.0.0-beta.6" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10c0/661d07b469ca6a76d8075a40aef66b90e22775fe2cc5462b349f5af14def39d8ee8572a63a1cd65acb91fcf7f546969d20b330f8afe480ab5360136aaacc0fc3 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"hookable@npm:^6.1.1": + version: 6.1.2 + resolution: "hookable@npm:6.1.2" + checksum: 10c0/ea5cdb1b1a1919b48cfbbfb55c8009f6843473ff50f67d6e959fd291cb2fd0a4934e85124bfd6531b1d2edf1e0675bc5954f85a12318a59b15c7a3e5667f0d4e + languageName: node + linkType: hard + +"human-id@npm:^4.2.0": + version: 4.2.1 + resolution: "human-id@npm:4.2.1" + bin: + human-id: dist/cli.js + checksum: 10c0/e7a6f89843fc10c3827d856f862b6b65678de22d97336524ff61ad0ff9414e9c6c8414bbcf7ccb329bdb7651db65666fa84aeac4b48aa3891f3ed8f0178a70c6 + languageName: node + linkType: hard + +"import-meta-resolve@npm:^4.2.0": + version: 4.2.0 + resolution: "import-meta-resolve@npm:4.2.0" + checksum: 10c0/3ee8aeecb61d19b49d2703987f977e9d1c7d4ba47db615a570eaa02fe414f40dfa63f7b953e842cbe8470d26df6371332bfcf21b2fd92b0112f9fea80dde2c4c + languageName: node + linkType: hard + +"import-without-cache@npm:^0.4.0": + version: 0.4.1 + resolution: "import-without-cache@npm:0.4.1" + checksum: 10c0/85c7f1581605813a892ce44d0b93e94c44f60f76fe558865b0a94f7b6ac3c6b6486388ca8d125e7b108ae1dedaebfc1f8adf67f3783ade0406c8c93ae9cfe656 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"jju@npm:^1.4.0": + version: 1.4.0 + resolution: "jju@npm:1.4.0" + checksum: 10c0/f3f444557e4364cfc06b1abf8331bf3778b26c0c8552ca54429bc0092652172fdea26cbffe33e1017b303d5aa506f7ede8571857400efe459cb7439180e2acad + languageName: node + linkType: hard + +"jsonc-parser@npm:^2.3.0": + version: 2.3.1 + resolution: "jsonc-parser@npm:2.3.1" + checksum: 10c0/b5e823612f6518a4d35e65d3c642e87b994c52a71b6d83d306d59f9b57003a1f6c64659808f0f1c3448991c28916d56faca45222f31ddb1a32effecdef0f0485 + languageName: node + linkType: hard + +"jsonc-parser@npm:^3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + +"launch-editor@npm:^2.14.1": + version: 2.14.1 + resolution: "launch-editor@npm:2.14.1" + dependencies: + picocolors: "npm:^1.1.1" + shell-quote: "npm:^1.8.4" + checksum: 10c0/bb0ab182086afaf1c391abd38d6fc9d5391cb43d0c2da1d96176f79e9995635cdf34dcfd8df3f756bb82d7bbbb7d37014d5eb312f337350889c0cff92db53dab + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-android-arm64@npm:1.33.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-arm64@npm:1.33.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-x64@npm:1.33.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-freebsd-x64@npm:1.33.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-musl@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.33.0": + version: 1.33.0 + resolution: "lightningcss@npm:1.33.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.33.0" + lightningcss-darwin-arm64: "npm:1.33.0" + lightningcss-darwin-x64: "npm:1.33.0" + lightningcss-freebsd-x64: "npm:1.33.0" + lightningcss-linux-arm-gnueabihf: "npm:1.33.0" + lightningcss-linux-arm64-gnu: "npm:1.33.0" + lightningcss-linux-arm64-musl: "npm:1.33.0" + lightningcss-linux-x64-gnu: "npm:1.33.0" + lightningcss-linux-x64-musl: "npm:1.33.0" + lightningcss-win32-arm64-msvc: "npm:1.33.0" + lightningcss-win32-x64-msvc: "npm:1.33.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3 + languageName: node + linkType: hard + +"magic-string@npm:^1.2.3": + version: 1.4.2 + resolution: "magic-string@npm:1.4.2" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.6.0" + checksum: 10c0/d3e222c202f99ec4861e7316f473f9b1cfe335ebdf38d4f67335f842f3fe248f01ee00fd3e1a8206bcbe9855e0c41515d7c210300d8fcf6eefbb982d79e423c6 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"nanoid@npm:^3.3.18": + version: 3.3.19 + resolution: "nanoid@npm:3.3.19" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/ce7afa6258bfaa00bdc8de291cfe9913280eaa403b7d9cff3db4e4200c1913e7163577df74897958da144db473a96c67f74f57db0f542fbfe409df677bae3255 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 13.0.2 + resolution: "node-gyp@npm:13.0.2" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^8.4.1" + which: "npm:^7.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/29d33ccf47ffeeb5e7af925550b416f698a26a72eb10975c7eb5775c1d0cecacd76d164a4aa45503d3ddcece209db65c712be00423c69381a4cd14e2e6540559 + languageName: node + linkType: hard + +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" + dependencies: + abbrev: "npm:^5.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a + languageName: node + linkType: hard + +"obug@npm:^2.1.4": + version: 2.2.1 + resolution: "obug@npm:2.2.1" + checksum: 10c0/8a7c001847d880fa6ba83c7bbee6a80fd271ee628439cd508c8647d53b217abb1759e2d5e4aaeb09a35d0a274bdb225a0a5a78eded3213d63e656153cf411b63 + languageName: node + linkType: hard + +"obug@npm:^3.0.0": + version: 3.0.0 + resolution: "obug@npm:3.0.0" + checksum: 10c0/985625b70ea05a39956da94f406040a0c7d27b1699a14af87b1ea75cba915b5957b75c4acdaccaefa2a86eaf40fc968bc00f6c28755a91496fc91a92b1513b38 + languageName: node + linkType: hard + +"oxfmt@npm:^0.70.0": + version: 0.70.0 + resolution: "oxfmt@npm:0.70.0" + dependencies: + "@oxfmt/binding-android-arm-eabi": "npm:0.70.0" + "@oxfmt/binding-android-arm64": "npm:0.70.0" + "@oxfmt/binding-darwin-arm64": "npm:0.70.0" + "@oxfmt/binding-darwin-x64": "npm:0.70.0" + "@oxfmt/binding-freebsd-x64": "npm:0.70.0" + "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.70.0" + "@oxfmt/binding-linux-arm-musleabihf": "npm:0.70.0" + "@oxfmt/binding-linux-arm64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-arm64-musl": "npm:0.70.0" + "@oxfmt/binding-linux-ppc64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-riscv64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-riscv64-musl": "npm:0.70.0" + "@oxfmt/binding-linux-s390x-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-x64-gnu": "npm:0.70.0" + "@oxfmt/binding-linux-x64-musl": "npm:0.70.0" + "@oxfmt/binding-openharmony-arm64": "npm:0.70.0" + "@oxfmt/binding-win32-arm64-msvc": "npm:0.70.0" + "@oxfmt/binding-win32-ia32-msvc": "npm:0.70.0" + "@oxfmt/binding-win32-x64-msvc": "npm:0.70.0" + tinypool: "npm:2.1.2" + peerDependencies: + svelte: ^5.0.0 + vite-plus: "*" + dependenciesMeta: + "@oxfmt/binding-android-arm-eabi": + optional: true + "@oxfmt/binding-android-arm64": + optional: true + "@oxfmt/binding-darwin-arm64": + optional: true + "@oxfmt/binding-darwin-x64": + optional: true + "@oxfmt/binding-freebsd-x64": + optional: true + "@oxfmt/binding-linux-arm-gnueabihf": + optional: true + "@oxfmt/binding-linux-arm-musleabihf": + optional: true + "@oxfmt/binding-linux-arm64-gnu": + optional: true + "@oxfmt/binding-linux-arm64-musl": + optional: true + "@oxfmt/binding-linux-ppc64-gnu": + optional: true + "@oxfmt/binding-linux-riscv64-gnu": + optional: true + "@oxfmt/binding-linux-riscv64-musl": + optional: true + "@oxfmt/binding-linux-s390x-gnu": + optional: true + "@oxfmt/binding-linux-x64-gnu": + optional: true + "@oxfmt/binding-linux-x64-musl": + optional: true + "@oxfmt/binding-openharmony-arm64": + optional: true + "@oxfmt/binding-win32-arm64-msvc": + optional: true + "@oxfmt/binding-win32-ia32-msvc": + optional: true + "@oxfmt/binding-win32-x64-msvc": + optional: true + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + bin: + oxfmt: bin/oxfmt + checksum: 10c0/9641cb661ed50183daacd90ecae767972f6c1cc8725b44d4f093014a9104cdc05659575ba94178b830fbe1ea53238bc0f81e70e132b4bcaaca2cbd4bb8ebbd42 + languageName: node + linkType: hard + +"oxlint@npm:^1.85.0": + version: 1.85.0 + resolution: "oxlint@npm:1.85.0" + dependencies: + "@oxlint/binding-android-arm-eabi": "npm:1.85.0" + "@oxlint/binding-android-arm64": "npm:1.85.0" + "@oxlint/binding-darwin-arm64": "npm:1.85.0" + "@oxlint/binding-darwin-x64": "npm:1.85.0" + "@oxlint/binding-freebsd-x64": "npm:1.85.0" + "@oxlint/binding-linux-arm-gnueabihf": "npm:1.85.0" + "@oxlint/binding-linux-arm-musleabihf": "npm:1.85.0" + "@oxlint/binding-linux-arm64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-arm64-musl": "npm:1.85.0" + "@oxlint/binding-linux-ppc64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-riscv64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-riscv64-musl": "npm:1.85.0" + "@oxlint/binding-linux-s390x-gnu": "npm:1.85.0" + "@oxlint/binding-linux-x64-gnu": "npm:1.85.0" + "@oxlint/binding-linux-x64-musl": "npm:1.85.0" + "@oxlint/binding-openharmony-arm64": "npm:1.85.0" + "@oxlint/binding-win32-arm64-msvc": "npm:1.85.0" + "@oxlint/binding-win32-ia32-msvc": "npm:1.85.0" + "@oxlint/binding-win32-x64-msvc": "npm:1.85.0" + peerDependencies: + oxlint-tsgolint: ">=7.0.2001" + vite-plus: "*" + dependenciesMeta: + "@oxlint/binding-android-arm-eabi": + optional: true + "@oxlint/binding-android-arm64": + optional: true + "@oxlint/binding-darwin-arm64": + optional: true + "@oxlint/binding-darwin-x64": + optional: true + "@oxlint/binding-freebsd-x64": + optional: true + "@oxlint/binding-linux-arm-gnueabihf": + optional: true + "@oxlint/binding-linux-arm-musleabihf": + optional: true + "@oxlint/binding-linux-arm64-gnu": + optional: true + "@oxlint/binding-linux-arm64-musl": + optional: true + "@oxlint/binding-linux-ppc64-gnu": + optional: true + "@oxlint/binding-linux-riscv64-gnu": + optional: true + "@oxlint/binding-linux-riscv64-musl": + optional: true + "@oxlint/binding-linux-s390x-gnu": + optional: true + "@oxlint/binding-linux-x64-gnu": + optional: true + "@oxlint/binding-linux-x64-musl": + optional: true + "@oxlint/binding-openharmony-arm64": + optional: true + "@oxlint/binding-win32-arm64-msvc": + optional: true + "@oxlint/binding-win32-ia32-msvc": + optional: true + "@oxlint/binding-win32-x64-msvc": + optional: true + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + bin: + oxlint: bin/oxlint + checksum: 10c0/8ed3d50ddd19a3beea4b0ceed69596c7587036ec830c8bd2d71cd6317d6bebaaf2d6b9b1783c3806e6187a310e37dbe42347cfd103f4bbff83cf5f99ba1d35b5 + languageName: node + linkType: hard + +"package-manager-detector@npm:^1.6.0, package-manager-detector@npm:^1.8.0": + version: 1.8.0 + resolution: "package-manager-detector@npm:1.8.0" + checksum: 10c0/4c8e2c47fdc874465d3b3b11934401db9d73470ccbdabeb092e46cb94abbc491d5befc8c271b5ea1ae9c6a881f44c2997e011873365bde9fd6c3dc001221ff7a + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.4, picomatch@npm:^4.0.7": + version: 4.0.7 + resolution: "picomatch@npm:4.0.7" + checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f + languageName: node + linkType: hard + +"postcss@npm:^8.5.28": + version: 8.5.28 + resolution: "postcss@npm:8.5.28" + dependencies: + nanoid: "npm:^3.3.18" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/9fe44215a6628d89c8a75184b92f5b2f77e16f9e5b13b39b9009bb7e8e6eccddf82c8854bae922db1f17ace6ef3445073fe38abff513caeb03e5285b1b55620a + languageName: node + linkType: hard + +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b + languageName: node + linkType: hard + +"quansync@npm:^1.0.0": + version: 1.0.0 + resolution: "quansync@npm:1.0.0" + checksum: 10c0/076542634399a0cc46078baab6b31acee7a88c5a435234345f645aedaa42bc6a63836e655fb39b1b21a83c98ec86a4b73ec5e2b2d6f3fdc22711eeeff9463253 + languageName: node + linkType: hard + +"resolve-pkg-maps@npm:^1.0.0": + version: 1.0.0 + resolution: "resolve-pkg-maps@npm:1.0.0" + checksum: 10c0/fb8f7bbe2ca281a73b7ef423a1cbc786fb244bd7a95cbe5c3fba25b27d327150beca8ba02f622baea65919a57e061eb5005204daa5f93ed590d9b77463a567ab + languageName: node + linkType: hard + +"rolldown-plugin-dts@npm:^0.28.5": + version: 0.28.6 + resolution: "rolldown-plugin-dts@npm:0.28.6" + dependencies: + dts-resolver: "npm:^3.0.0" + get-tsconfig: "npm:5.0.0-beta.6" + obug: "npm:^3.0.0" + yuku-ast: "npm:^0.10.1" + yuku-codegen: "npm:^0.10.1" + yuku-parser: "npm:^0.10.1" + peerDependencies: + "@typescript/native-preview": "*" + "@volar/typescript": ~2.4.0 + "@vue/language-core": ~3.2.0 || ~3.3.0 + rolldown: ^1.2.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + "@typescript/native-preview": + optional: true + "@volar/typescript": + optional: true + "@vue/language-core": + optional: true + typescript: + optional: true + vue-tsc: + optional: true + checksum: 10c0/36b22e9e2c2bf32c8436a576823fe33cae2c13adb1d9def68853e406c34900a76afe4c0c688072295a9c3e0a25928b5ebc43837d41db76a6f1aa3729e483818a + languageName: node + linkType: hard + +"rolldown@npm:~1.2.7, rolldown@npm:~1.2.9": + version: 1.2.11 + resolution: "rolldown@npm:1.2.11" + dependencies: + "@oxc-project/types": "npm:=0.151.0" + "@rolldown/binding-android-arm-eabi": "npm:1.2.11" + "@rolldown/binding-android-arm64": "npm:1.2.11" + "@rolldown/binding-darwin-arm64": "npm:1.2.11" + "@rolldown/binding-darwin-x64": "npm:1.2.11" + "@rolldown/binding-freebsd-x64": "npm:1.2.11" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.11" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.11" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.11" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.11" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.11" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.11" + "@rolldown/binding-linux-x64-musl": "npm:1.2.11" + "@rolldown/binding-openharmony-arm64": "npm:1.2.11" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.11" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.11" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm-eabi": + optional: true + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/43dfd083fb625a254c039bddfe04f3dd1d7cf8b84291ead835e3702e8050b7fee5e702c8280d3000b2541c0e24aaa3110bac4251bc2ca50a85fe1c8bbb48d2f5 + languageName: node + linkType: hard + +"semver@npm:^7.3.5, semver@npm:^7.8.1": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + +"shell-quote@npm:^1.8.4": + version: 1.10.0 + resolution: "shell-quote@npm:1.10.0" + checksum: 10c0/46ee59bfd972ce6a45500c44ed130dff2d0a7d6fbac9841e59d548518cad8060a06393c9a5dcbc0cede294ad80b2a2cd8c904679e09265f53efc0a0879f30961 + languageName: node + linkType: hard + +"sisteransi@npm:^1.0.5": + version: 1.0.5 + resolution: "sisteransi@npm:1.0.5" + checksum: 10c0/230ac975cca485b7f6fe2b96a711aa62a6a26ead3e6fb8ba17c5a00d61b8bed0d7adc21f5626b70d7c33c62ff4e63933017a6462942c719d1980bb0b1207ad46 + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"std-env@npm:^4.2.0": + version: 4.2.0 + resolution: "std-env@npm:4.2.0" + checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69 + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.22 + resolution: "tar@npm:7.5.22" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea + languageName: node + linkType: hard + +"tinybench@npm:^6.1.4, tinybench@npm:^6.2.0": + version: 6.2.0 + resolution: "tinybench@npm:6.2.0" + checksum: 10c0/ae821428f0412324611d449290f8497c886c94f04bad8e19aa9b3a5060b4802e738c49f9eb493e4fd17e4d5e792290565e5079be647df1995aea731c82363815 + languageName: node + linkType: hard + +"tinyexec@npm:^1.3.0, tinyexec@npm:^1.3.1": + version: 1.3.1 + resolution: "tinyexec@npm:1.3.1" + checksum: 10c0/c590369cb3fa73cc18a3998caec8a00f9712d91309c881be3ac52b160fb2f80c7b1fbf3591858ab6abab47ad55385b0c86ec19982e32441a01f55298dab9ccd2 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"tinypool@npm:2.1.2": + version: 2.1.2 + resolution: "tinypool@npm:2.1.2" + checksum: 10c0/4d8c0cf2d9710eff4aa9cb4318fecaac09d847f1d7dce3eb314d36bea35ed23f754315592a3c00c1d0aa21d9eb4a1f69e84c69feb8517b4cd769e1e0a7ee308e + languageName: node + linkType: hard + +"tree-kill@npm:^1.2.2": + version: 1.2.2 + resolution: "tree-kill@npm:1.2.2" + bin: + tree-kill: cli.js + checksum: 10c0/7b1b7c7f17608a8f8d20a162e7957ac1ef6cd1636db1aba92f4e072dc31818c2ff0efac1e3d91064ede67ed5dc57c565420531a8134090a12ac10cf792ab14d2 + languageName: node + linkType: hard + +"tsdown@npm:^0.23.0": + version: 0.23.0 + resolution: "tsdown@npm:0.23.0" + dependencies: + cac: "npm:^7.0.0" + defu: "npm:^6.1.7" + empathic: "npm:^2.0.1" + hookable: "npm:^6.1.1" + import-without-cache: "npm:^0.4.0" + obug: "npm:^2.1.4" + picomatch: "npm:^4.0.7" + rolldown: "npm:~1.2.7" + rolldown-plugin-dts: "npm:^0.28.5" + tinyexec: "npm:^1.3.1" + tinyglobby: "npm:^0.2.17" + tree-kill: "npm:^1.2.2" + unconfig-core: "npm:^7.5.0" + verkit: "npm:^0.4.0" + peerDependencies: + "@arethetypeswrong/core": ^0.18.1 + "@tsdown/css": 0.23.0 + "@tsdown/exe": 0.23.0 + "@vitejs/devtools": "*" + publint: ^0.3.8 + tsx: "*" + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ">=0.5.0" + unrun: "*" + peerDependenciesMeta: + "@arethetypeswrong/core": + optional: true + "@tsdown/css": + optional: true + "@tsdown/exe": + optional: true + "@vitejs/devtools": + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + bin: + tsdown: ./dist/run.mjs + checksum: 10c0/0b9f77653ec9955a5b830576d6a4d6ba1feb82d4adb7624a1b529e34fdf772c165cdbf817ef44ad907d507fa1170ad960ba99bd88f448f3d57e0da07cf6c9643 + languageName: node + linkType: hard + +"typescript-5.5@npm:typescript@5.5.4": + version: 5.5.4 + resolution: "typescript@npm:5.5.4" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/422be60f89e661eab29ac488c974b6cc0a660fb2228003b297c3d10c32c90f3bcffc1009b43876a082515a3c376b1eefcce823d6e78982e6878408b9a923199c + languageName: node + linkType: hard + +"typescript-5.9@npm:typescript@5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript-current@npm:typescript@6.0.3, typescript@npm:~6.0.3": + version: 6.0.3 + resolution: "typescript@npm:6.0.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 + languageName: node + linkType: hard + +"typescript-legacy@npm:typescript@4.9.5": + version: 4.9.5 + resolution: "typescript@npm:4.9.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/5f6cad2e728a8a063521328e612d7876e12f0d8a8390d3b3aaa452a6a65e24e9ac8ea22beb72a924fd96ea0a49ea63bb4e251fb922b12eedfb7f7a26475e5c56 + languageName: node + linkType: hard + +"typescript-minimum@npm:typescript@5.0.4": + version: 5.0.4 + resolution: "typescript@npm:5.0.4" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/2f5bd1cead194905957cb34e220b1d6ff1662399adef8ec1864f74620922d860ee35b6e50eafb3b636ea6fd437195e454e1146cb630a4236b5095ed7617395c2 + languageName: node + linkType: hard + +"typescript-template-language-service-decorator@npm:^2.3.2": + version: 2.3.2 + resolution: "typescript-template-language-service-decorator@npm:2.3.2" + checksum: 10c0/6f3b5af98372a18c261db45263fe22e6714169562db00af2e77fac788b96e62107f57a49aa72de3004bf50639ccb10e000c6ccb5406000e53cab69b6ab4a05cb + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A~6.0.3#optional!builtin": + version: 6.0.3 + resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df + languageName: node + linkType: hard + +"unconfig-core@npm:^7.5.0": + version: 7.5.0 + resolution: "unconfig-core@npm:7.5.0" + dependencies: + "@quansync/fs": "npm:^1.0.0" + quansync: "npm:^1.0.0" + checksum: 10c0/9c9f745254aa8e267140430a432353d17ee87f625b0ea0668e189d88736828d117b66bd2dd41f1d48bd40d44d5b7c7d160e8b7996a60c8f484e2975ef73c7cb7 + languageName: node + linkType: hard + +"undici-types@npm:>=7.24.0 <7.24.7": + version: 7.24.6 + resolution: "undici-types@npm:7.24.6" + checksum: 10c0/d9cd8befb643ac904615c280a095ba4240531f6bb4a5e75a22a7483630ca8d3f1016d2ab6ace6ceda1f63b3a2db2fe037fafe121d6917a0187573aa548ff78ca + languageName: node + linkType: hard + +"undici@npm:^8.4.1": + version: 8.11.2 + resolution: "undici@npm:8.11.2" + checksum: 10c0/106f6991178fb7562fd90cdec7993c5f807a9428682306091f0c4b27fc474ec291f868e29972c6252988bf061ad193df943b3b99e775be157a28d92bbb95699d + languageName: node + linkType: hard + +"verkit@npm:^0.4.0": + version: 0.4.1 + resolution: "verkit@npm:0.4.1" + checksum: 10c0/b184269beec1d8ee90915d504f3d3671663a9c840cae01e664ea2614fcd5ff7e42472ce0b2795b66a967abaef6dfe2ecf89c5d2a6f1826c8cab9e65a41b7fd0f + languageName: node + linkType: hard + +"vite@npm:^8.3.1": + version: 8.3.1 + resolution: "vite@npm:8.3.1" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.33.0" + picomatch: "npm:^4.0.7" + postcss: "npm:^8.5.28" + rolldown: "npm:~1.2.9" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/a91f4fee1cd331912ee3ac45f49900044846e28b8632ae734fe1978630e643e503d04181b2aeac59f70556fc51137dd49179fd39cbeb72a3e5f0bce88d2bcd30 + languageName: node + linkType: hard + +"vitest@npm:^5.0.2": + version: 5.0.2 + resolution: "vitest@npm:5.0.2" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/mocker": "npm:5.0.2" + chai: "npm:^6.2.2" + es-module-lexer: "npm:^2.3.2" + expect-type: "npm:^1.4.0" + magic-string: "npm:^1.2.3" + obug: "npm:^2.1.4" + picomatch: "npm:^4.0.7" + std-env: "npm:^4.2.0" + tinybench: "npm:^6.1.4" + tinyexec: "npm:^1.3.0" + tinyglobby: "npm:^0.2.17" + why-is-node-running: "npm:^3.2.1" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 5.0.2 + "@vitest/browser-preview": 5.0.2 + "@vitest/browser-webdriverio": ^5.0.0-beta.5 || >=5.0.0 + "@vitest/coverage-istanbul": 5.0.2 + "@vitest/coverage-v8": 5.0.2 + "@vitest/ui": 5.0.2 + happy-dom: "*" + jsdom: "*" + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/e86919375db85ddecc6308076341f66a9d51c3b28aa7966ea6bcb0f45234b5e2883602c17e509abfa6d729aaf8113b3adf7b59a8316079c2c9a48dbb1b248897 + languageName: node + linkType: hard + +"vscode-css-languageservice@npm:6.3.10": + version: 6.3.10 + resolution: "vscode-css-languageservice@npm:6.3.10" + dependencies: + "@vscode/l10n": "npm:^0.0.18" + vscode-languageserver-textdocument: "npm:^1.0.12" + vscode-languageserver-types: "npm:3.17.5" + vscode-uri: "npm:^3.1.0" + checksum: 10c0/3f6228d85579c0306343c734716c17128757f3a6e44625deac06622fe248f05fe15da1901581aa50cb8d9539137a49d19f05dca6d13feb2e6bc9f3d9784236bd + languageName: node + linkType: hard + +"vscode-languageserver-textdocument@npm:1.0.15, vscode-languageserver-textdocument@npm:^1.0.1, vscode-languageserver-textdocument@npm:^1.0.12": + version: 1.0.15 + resolution: "vscode-languageserver-textdocument@npm:1.0.15" + checksum: 10c0/b2e5c47fe9b0c3278855aa9ef99a3de7306fe5d45399bf0f6d59f5fb4daebff6dcbadf041e991296560b0c9367627f0014f1d7e40136d2b459f21c19b671df10 + languageName: node + linkType: hard + +"vscode-languageserver-types@npm:3.17.5": + version: 3.17.5 + resolution: "vscode-languageserver-types@npm:3.17.5" + checksum: 10c0/1e1260de79a2cc8de3e46f2e0182cdc94a7eddab487db5a3bd4ee716f67728e685852707d72c059721ce500447be9a46764a04f0611e94e4321ffa088eef36f8 + languageName: node + linkType: hard + +"vscode-languageserver-types@npm:3.18.4, vscode-languageserver-types@npm:^3.15.1": + version: 3.18.4 + resolution: "vscode-languageserver-types@npm:3.18.4" + checksum: 10c0/95a58eba94900bd9e685b44b840497c40709a8f643a4fa0945daa40affa45246ee843d786aa5ce9d64fe573cec1ef9530d3519ae01e8b4aa0ff2c83ba7608ccc + languageName: node + linkType: hard + +"vscode-uri@npm:^3.0.8, vscode-uri@npm:^3.1.0": + version: 3.2.0 + resolution: "vscode-uri@npm:3.2.0" + checksum: 10c0/65b4d78f2d014ee359ba36a81605bbd8a420d4c13d3fdba61b44b301ea416bf4b12637253ee72c0f30c1cd774c3649d667674c2fbf00c931667b869158f8519f + languageName: node + linkType: hard + +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d + languageName: node + linkType: hard + +"why-is-node-running@npm:^3.2.1": + version: 3.2.2 + resolution: "why-is-node-running@npm:3.2.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/47307bfc2f48dd5228a92103dc43a7cadafbc6b6791c114fd27e03ac271a5082849a54f7e5f5cb261ea68f0d4ad40d9cb503b55812a6b62e67de0c57039fb95b + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"yaml@npm:^2.9.0": + version: 2.9.1 + resolution: "yaml@npm:2.9.1" + bin: + yaml: bin.mjs + checksum: 10c0/9d06676ef02b89559a8b10a71882bd2cdae81e8393dc8dbc871615c3259475d9609ad973d19cb3c9e71e72d6206e3d1e7aa241c7e55f3d16fd2e77db305827d6 + languageName: node + linkType: hard + +"yuku-ast@npm:^0.10.1, yuku-ast@npm:^0.10.2": + version: 0.10.2 + resolution: "yuku-ast@npm:0.10.2" + dependencies: + "@yuku-toolchain/types": "npm:^0.10.2" + checksum: 10c0/ecaaf37d5c7436f3eeade5ac68e50aaded476387008e8ef9643da8d3369c0d824de028c44d73b2d8ed3ccf5adafae770bbac5d0dec63c8e7b3957b784b45530d + languageName: node + linkType: hard + +"yuku-codegen@npm:^0.10.1": + version: 0.10.2 + resolution: "yuku-codegen@npm:0.10.2" + dependencies: + "@yuku-codegen/binding-android-arm64": "npm:0.10.2" + "@yuku-codegen/binding-darwin-arm64": "npm:0.10.2" + "@yuku-codegen/binding-darwin-x64": "npm:0.10.2" + "@yuku-codegen/binding-freebsd-x64": "npm:0.10.2" + "@yuku-codegen/binding-linux-arm-gnu": "npm:0.10.2" + "@yuku-codegen/binding-linux-arm-musl": "npm:0.10.2" + "@yuku-codegen/binding-linux-arm64-gnu": "npm:0.10.2" + "@yuku-codegen/binding-linux-arm64-musl": "npm:0.10.2" + "@yuku-codegen/binding-linux-x64-gnu": "npm:0.10.2" + "@yuku-codegen/binding-linux-x64-musl": "npm:0.10.2" + "@yuku-codegen/binding-win32-arm64": "npm:0.10.2" + "@yuku-codegen/binding-win32-x64": "npm:0.10.2" + "@yuku-toolchain/types": "npm:^0.10.2" + dependenciesMeta: + "@yuku-codegen/binding-android-arm64": + optional: true + "@yuku-codegen/binding-darwin-arm64": + optional: true + "@yuku-codegen/binding-darwin-x64": + optional: true + "@yuku-codegen/binding-freebsd-x64": + optional: true + "@yuku-codegen/binding-linux-arm-gnu": + optional: true + "@yuku-codegen/binding-linux-arm-musl": + optional: true + "@yuku-codegen/binding-linux-arm64-gnu": + optional: true + "@yuku-codegen/binding-linux-arm64-musl": + optional: true + "@yuku-codegen/binding-linux-x64-gnu": + optional: true + "@yuku-codegen/binding-linux-x64-musl": + optional: true + "@yuku-codegen/binding-win32-arm64": + optional: true + "@yuku-codegen/binding-win32-x64": + optional: true + checksum: 10c0/df143c7be4a4db7fba1489bd2d741f2b666077837b1cc66423ac399244e1d1c6b1fd4b0ff31224eeced440db5d2180a3c0864d9bf674956bc3425c492bb74bd2 + languageName: node + linkType: hard + +"yuku-parser@npm:^0.10.1": + version: 0.10.2 + resolution: "yuku-parser@npm:0.10.2" + dependencies: + "@yuku-parser/binding-android-arm64": "npm:0.10.2" + "@yuku-parser/binding-darwin-arm64": "npm:0.10.2" + "@yuku-parser/binding-darwin-x64": "npm:0.10.2" + "@yuku-parser/binding-freebsd-x64": "npm:0.10.2" + "@yuku-parser/binding-linux-arm-gnu": "npm:0.10.2" + "@yuku-parser/binding-linux-arm-musl": "npm:0.10.2" + "@yuku-parser/binding-linux-arm64-gnu": "npm:0.10.2" + "@yuku-parser/binding-linux-arm64-musl": "npm:0.10.2" + "@yuku-parser/binding-linux-x64-gnu": "npm:0.10.2" + "@yuku-parser/binding-linux-x64-musl": "npm:0.10.2" + "@yuku-parser/binding-win32-arm64": "npm:0.10.2" + "@yuku-parser/binding-win32-x64": "npm:0.10.2" + "@yuku-toolchain/types": "npm:^0.10.2" + yuku-ast: "npm:^0.10.2" + dependenciesMeta: + "@yuku-parser/binding-android-arm64": + optional: true + "@yuku-parser/binding-darwin-arm64": + optional: true + "@yuku-parser/binding-darwin-x64": + optional: true + "@yuku-parser/binding-freebsd-x64": + optional: true + "@yuku-parser/binding-linux-arm-gnu": + optional: true + "@yuku-parser/binding-linux-arm-musl": + optional: true + "@yuku-parser/binding-linux-arm64-gnu": + optional: true + "@yuku-parser/binding-linux-arm64-musl": + optional: true + "@yuku-parser/binding-linux-x64-gnu": + optional: true + "@yuku-parser/binding-linux-x64-musl": + optional: true + "@yuku-parser/binding-win32-arm64": + optional: true + "@yuku-parser/binding-win32-x64": + optional: true + checksum: 10c0/6e8e06dca54ba1c6b130d3ac3456052912d270076dfba820ded61b0f575d097a233b8d4bbd324d5275c208d08a2d9e9b8544e517b8655813478701aa01151d6d + languageName: node + linkType: hard From 50ed5f8707e4d0a68ffcf4b332028bbce79f6d95 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sun, 27 Sep 2026 14:55:10 -0400 Subject: [PATCH 2/5] feat: modernize the language service and remove false diagnostics on working styled-components code Reorganizes the plugin into configuration, template substitution, virtual document, tsserver, and one-file-per-feature modules while keeping 1.0.1's public constructor, mapping contracts, and TypeScript 5.0 floor. Interpolations in property names, at-rule conditions, selectors, and keyframe percentages are read by their role, comments and strings no longer mislead that reading, and JavaScript escapes are checked as styled-components receives them. Adapts to styled-components v7 templates, maps positions exactly, caches validation within a size bound, and keeps the common path free of edge-case work. Co-authored-by: usercao --- .changeset/block-position-interpolations.md | 5 + .changeset/completions.md | 5 + .changeset/configuration-feedback.md | 5 + .changeset/diagnostics-and-mapping-fixes.md | 14 + ...cs-caching-and-substitution-performance.md | 5 + .changeset/emmet-and-completion-placement.md | 5 + .../interpolated-names-and-conditions.md | 5 + .changeset/javascript-escapes.md | 5 + .changeset/nested-at-rule-completions.md | 5 + .changeset/nested-layer-declarations.md | 5 + .changeset/package-entry-points-and-types.md | 7 + .changeset/plugin-resilience.md | 5 + .../supported-hosts-and-typescript-floor.md | 5 + .changeset/value-css-fragments.md | 5 + src/_configuration.ts | 45 - src/_language-service.ts | 511 --- src/_logger.ts | 11 - src/_plugin.ts | 66 - src/_substituter.ts | 130 - src/_virtual-document-provider.ts | 76 - src/api.ts | 26 +- src/configuration/plugin-configuration.ts | 418 +++ src/features/code-actions.ts | 119 + src/features/completions.ts | 598 ++++ src/features/css-diagnostic-code.ts | 8 + src/features/diagnostics.ts | 345 ++ src/features/folding.ts | 50 + src/features/hover.ts | 67 + src/features/markup-text.ts | 6 + src/features/styles-language-services.ts | 52 + src/index.ts | 20 +- src/template-language-service.ts | 259 ++ src/template-settings.ts | 18 + src/template/template-substitutions.ts | 1012 ++++++ src/test/substituter.test.ts | 212 -- .../plugin-identity.ts} | 2 +- src/tsserver/tsserver-logger.ts | 12 + src/tsserver/tsserver-plugin.ts | 68 + src/virtual-document/css-code-scanner.ts | 585 ++++ src/virtual-document/javascript-escapes.ts | 959 ++++++ .../styled-virtual-document-provider.ts | 544 ++++ src/virtual-document/template-line-map.ts | 315 ++ .../virtual-document-session-provider.ts | 77 + test/unit/bounded-worker-noop-fixture.ts | 12 + test/unit/bounded-worker.test.ts | 80 + test/unit/changelog.test.ts | 388 +++ test/unit/create-template-context.ts | 63 + test/unit/javascript-escapes.test.ts | 425 +++ test/unit/plugin-configuration.test.ts | 334 ++ .../styled-virtual-document-provider.test.ts | 1209 +++++++ test/unit/template-language-service.test.ts | 2896 +++++++++++++++++ test/unit/template-substitutions.test.ts | 1188 +++++++ test/unit/tsserver-plugin.test.ts | 107 + test/unit/validation-cache.test.ts | 149 + .../virtual-document-session-provider.test.ts | 136 + test/unit/with-deadline.test.ts | 61 + 56 files changed, 12679 insertions(+), 1061 deletions(-) create mode 100644 .changeset/block-position-interpolations.md create mode 100644 .changeset/completions.md create mode 100644 .changeset/configuration-feedback.md create mode 100644 .changeset/diagnostics-and-mapping-fixes.md create mode 100644 .changeset/diagnostics-caching-and-substitution-performance.md create mode 100644 .changeset/emmet-and-completion-placement.md create mode 100644 .changeset/interpolated-names-and-conditions.md create mode 100644 .changeset/javascript-escapes.md create mode 100644 .changeset/nested-at-rule-completions.md create mode 100644 .changeset/nested-layer-declarations.md create mode 100644 .changeset/package-entry-points-and-types.md create mode 100644 .changeset/plugin-resilience.md create mode 100644 .changeset/supported-hosts-and-typescript-floor.md create mode 100644 .changeset/value-css-fragments.md delete mode 100644 src/_configuration.ts delete mode 100644 src/_language-service.ts delete mode 100644 src/_logger.ts delete mode 100644 src/_plugin.ts delete mode 100644 src/_substituter.ts delete mode 100644 src/_virtual-document-provider.ts create mode 100644 src/configuration/plugin-configuration.ts create mode 100644 src/features/code-actions.ts create mode 100644 src/features/completions.ts create mode 100644 src/features/css-diagnostic-code.ts create mode 100644 src/features/diagnostics.ts create mode 100644 src/features/folding.ts create mode 100644 src/features/hover.ts create mode 100644 src/features/markup-text.ts create mode 100644 src/features/styles-language-services.ts create mode 100644 src/template-language-service.ts create mode 100644 src/template-settings.ts create mode 100644 src/template/template-substitutions.ts delete mode 100644 src/test/substituter.test.ts rename src/{_config.ts => tsserver/plugin-identity.ts} (66%) create mode 100644 src/tsserver/tsserver-logger.ts create mode 100644 src/tsserver/tsserver-plugin.ts create mode 100644 src/virtual-document/css-code-scanner.ts create mode 100644 src/virtual-document/javascript-escapes.ts create mode 100644 src/virtual-document/styled-virtual-document-provider.ts create mode 100644 src/virtual-document/template-line-map.ts create mode 100644 src/virtual-document/virtual-document-session-provider.ts create mode 100644 test/unit/bounded-worker-noop-fixture.ts create mode 100644 test/unit/bounded-worker.test.ts create mode 100644 test/unit/changelog.test.ts create mode 100644 test/unit/create-template-context.ts create mode 100644 test/unit/javascript-escapes.test.ts create mode 100644 test/unit/plugin-configuration.test.ts create mode 100644 test/unit/styled-virtual-document-provider.test.ts create mode 100644 test/unit/template-language-service.test.ts create mode 100644 test/unit/template-substitutions.test.ts create mode 100644 test/unit/tsserver-plugin.test.ts create mode 100644 test/unit/validation-cache.test.ts create mode 100644 test/unit/virtual-document-session-provider.test.ts create mode 100644 test/unit/with-deadline.test.ts diff --git a/.changeset/block-position-interpolations.md b/.changeset/block-position-interpolations.md new file mode 100644 index 0000000..10ec3de --- /dev/null +++ b/.changeset/block-position-interpolations.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Mixin interpolations that share a line with other CSS, such as `color: red; ${mixin}`, and values split across lines no longer report false `colon expected` or `semi-colon expected` errors. diff --git a/.changeset/completions.md b/.changeset/completions.md new file mode 100644 index 0000000..096568f --- /dev/null +++ b/.changeset/completions.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Completions now follow plugin configuration changes without a restart. A completion or code fix whose edit would land outside the template is dropped instead of producing a broken edit. diff --git a/.changeset/configuration-feedback.md b/.changeset/configuration-feedback.md new file mode 100644 index 0000000..e657bc1 --- /dev/null +++ b/.changeset/configuration-feedback.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Invalid or misspelled plugin settings are now reported in the TypeScript server log, naming the setting, what it accepts, and the closest match, instead of being ignored silently. Common spellings such as `"off"`, `"warn"`, and `"Error"` for lint levels are now accepted. diff --git a/.changeset/diagnostics-and-mapping-fixes.md b/.changeset/diagnostics-and-mapping-fixes.md new file mode 100644 index 0000000..50dd723 --- /dev/null +++ b/.changeset/diagnostics-and-mapping-fixes.md @@ -0,0 +1,14 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Errors are placed more accurately and appear in more cases: + +- Errors at the end of a template, such as an unclosed block, point inside the template instead of past its closing backtick, and an unclosed block can be folded. +- A stray `}` or `;` is always reported, underlined where it is written. +- Diagnostics work for files not open in the editor. +- Templates containing U+2028 or U+2029 line separators map positions correctly. +- A `;` or `}` inside a comment or an unquoted `url()` no longer causes a false `colon expected`. +- `styled.keyframes` templates are checked as keyframes. +- Code-fix requests for other error codes no longer return the plugin's fixes. +- `getTemplateSettings().getSubstitutions` from `./api` keeps its output the same length as its input for any spans. diff --git a/.changeset/diagnostics-caching-and-substitution-performance.md b/.changeset/diagnostics-caching-and-substitution-performance.md new file mode 100644 index 0000000..f84a4ba --- /dev/null +++ b/.changeset/diagnostics-caching-and-substitution-performance.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Diagnostics and hover are faster in large files and in templates with many interpolations. Results for unchanged templates are reused across requests, using at most 4 MB of memory per project. diff --git a/.changeset/emmet-and-completion-placement.md b/.changeset/emmet-and-completion-placement.md new file mode 100644 index 0000000..a99643a --- /dev/null +++ b/.changeset/emmet-and-completion-placement.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Emmet no longer offers whole declarations where none fit, such as inside a value: typing `display: fl` used to offer `float: left;`. Completions no longer appear inside CSS comments and strings, and an empty template now offers property suggestions. diff --git a/.changeset/interpolated-names-and-conditions.md b/.changeset/interpolated-names-and-conditions.md new file mode 100644 index 0000000..1063c3a --- /dev/null +++ b/.changeset/interpolated-names-and-conditions.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Interpolations in property names (`padding-${side}: 4px;`), at-rule conditions (`@media screen and ${query} {`), selectors (`&.${className}:hover {`), and keyframe percentages (`${step}% {`) no longer report false errors, including when a nearby comment or string holds a `;` or `{`, or a comment sits before the `{` or `:`. 1.0.1 reported these errors as well. diff --git a/.changeset/javascript-escapes.md b/.changeset/javascript-escapes.md new file mode 100644 index 0000000..d6b88f0 --- /dev/null +++ b/.changeset/javascript-escapes.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Templates are checked as the CSS styled-components actually receives, with JavaScript escapes such as `\"`, `\n`, and `\x41` read as the characters they stand for. Escapes in strings, names, and `url()` no longer report false errors, and an error on an escaped name underlines the whole escape. 1.0.1 reported these errors as well. diff --git a/.changeset/nested-at-rule-completions.md b/.changeset/nested-at-rule-completions.md new file mode 100644 index 0000000..84e8e01 --- /dev/null +++ b/.changeset/nested-at-rule-completions.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Typing `@` inside a component now suggests the at-rules that work there, such as `@media`, `@container`, `@layer`, and `@keyframes`, with their documentation. diff --git a/.changeset/nested-layer-declarations.md b/.changeset/nested-layer-declarations.md new file mode 100644 index 0000000..33a14dd --- /dev/null +++ b/.changeset/nested-layer-declarations.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +A `@layer` block with declarations directly inside a component, such as `@layer utilities { color: red; }`, no longer reports a false `{ expected`. A component and a global style with the same text no longer share results. diff --git a/.changeset/package-entry-points-and-types.md b/.changeset/package-entry-points-and-types.md new file mode 100644 index 0000000..876376f --- /dev/null +++ b/.changeset/package-entry-points-and-types.md @@ -0,0 +1,7 @@ +--- +'@styled/typescript-styled-plugin': minor +--- + +`@styled/typescript-styled-plugin/api` is now a public export for both `require()` and `import()`, and the deep `lib/api` imports keep working. It adds types for building configuration by hand and for injecting custom language services. Lint settings accept `unknownAtRules`, and `globalCss` (Pigment CSS) is a default tag. 1.0.1's private underscore-prefixed modules are not included. + +Thanks to @usercao for the modernization work most of this release builds on. diff --git a/.changeset/plugin-resilience.md b/.changeset/plugin-resilience.md new file mode 100644 index 0000000..d964ebe --- /dev/null +++ b/.changeset/plugin-resilience.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +An unexpected error inside the plugin, or malformed plugin configuration, is logged instead of failing the editor's whole response for the file, so TypeScript's own diagnostics and completions keep working. diff --git a/.changeset/supported-hosts-and-typescript-floor.md b/.changeset/supported-hosts-and-typescript-floor.md new file mode 100644 index 0000000..562169f --- /dev/null +++ b/.changeset/supported-hosts-and-typescript-floor.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': minor +--- + +On TypeScript older than 5.0, the plugin now logs a clear message and leaves the editor's TypeScript features working, instead of crashing on activation. diff --git a/.changeset/value-css-fragments.md b/.changeset/value-css-fragments.md new file mode 100644 index 0000000..a8c9d9b --- /dev/null +++ b/.changeset/value-css-fragments.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +A `css` fragment holding a single value, such as `` css`1px solid red` ``, is checked as a value instead of reporting a false `} expected`, and offers value completions. diff --git a/src/_configuration.ts b/src/_configuration.ts deleted file mode 100644 index ab49a9a..0000000 --- a/src/_configuration.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -export interface StyledPluginConfiguration { - readonly tags: ReadonlyArray; - readonly validate: boolean; - readonly lint: { [key: string]: any }; - readonly emmet: { [key: string]: any }; -} - -export class ConfigurationManager { - - private static readonly defaultConfiguration: StyledPluginConfiguration = { - tags: ['styled', 'css', 'extend', 'injectGlobal', 'createGlobalStyle', 'keyframes'], - validate: true, - lint: { - emptyRules: 'ignore', - }, - emmet: {}, - }; - - private readonly _configUpdatedListeners = new Set<() => void>(); - - public get config(): StyledPluginConfiguration { return this._configuration; } - private _configuration: StyledPluginConfiguration = ConfigurationManager.defaultConfiguration; - - public updateFromPluginConfig(config: StyledPluginConfiguration) { - const lint = Object.assign({}, ConfigurationManager.defaultConfiguration.lint, config.lint || {}); - - this._configuration = { - tags: config.tags || ConfigurationManager.defaultConfiguration.tags, - validate: typeof config.validate !== 'undefined' ? config.validate : ConfigurationManager.defaultConfiguration.validate, - lint, - emmet: config.emmet || ConfigurationManager.defaultConfiguration.emmet, - }; - - for (const listener of this._configUpdatedListeners) { - listener(); - } - } - - public onUpdatedConfig(listener: () => void) { - this._configUpdatedListeners.add(listener); - } -} \ No newline at end of file diff --git a/src/_language-service.ts b/src/_language-service.ts deleted file mode 100644 index c18b4ac..0000000 --- a/src/_language-service.ts +++ /dev/null @@ -1,511 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// -// Original code forked from https://github.com/Quramy/ts-graphql-plugin - -import { doComplete as emmetDoComplete } from '@vscode/emmet-helper'; -import { Logger, TemplateContext, TemplateLanguageService } from 'typescript-template-language-service-decorator'; -import * as ts from 'typescript/lib/tsserverlibrary'; -import { - FoldingRange, - LanguageService, - getCSSLanguageService, - getSCSSLanguageService, -} from 'vscode-css-languageservice'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import * as vscode from 'vscode-languageserver-types'; -import * as config from './_config'; -import { ConfigurationManager } from './_configuration'; -import { VirtualDocumentProvider } from './_virtual-document-provider'; - -const cssErrorCode = 9999; - -function arePositionsEqual(left: ts.LineAndCharacter, right: ts.LineAndCharacter): boolean { - return left.line === right.line && left.character === right.character; -} - -function isAfter(left: vscode.Position, right: vscode.Position): boolean { - return right.line > left.line || (right.line === left.line && right.character >= left.character); -} - -function overlaps(a: vscode.Range, b: vscode.Range): boolean { - return !isAfter(a.end, b.start) && !isAfter(b.end, a.start); -} - -const emptyCompletionList: vscode.CompletionList = { - items: [], - isIncomplete: false, -}; - -class CompletionsCache { - private _cachedCompletionsFile?: string; - private _cachedCompletionsPosition?: ts.LineAndCharacter; - private _cachedCompletionsContent?: string; - private _completions?: vscode.CompletionList; - - public getCached(context: TemplateContext, position: ts.LineAndCharacter): vscode.CompletionList | undefined { - if ( - this._completions && - context.fileName === this._cachedCompletionsFile && - this._cachedCompletionsPosition && - arePositionsEqual(position, this._cachedCompletionsPosition) && - context.text === this._cachedCompletionsContent - ) { - return this._completions; - } - - return undefined; - } - - public updateCached(context: TemplateContext, position: ts.LineAndCharacter, completions: vscode.CompletionList) { - this._cachedCompletionsFile = context.fileName; - this._cachedCompletionsPosition = position; - this._cachedCompletionsContent = context.text; - this._completions = completions; - } -} - -export class StyledTemplateLanguageService implements TemplateLanguageService { - private _cssLanguageService?: LanguageService; - private _scssLanguageService?: LanguageService; - private _completionsCache = new CompletionsCache(); - - constructor( - private readonly typescript: typeof ts, - private readonly configurationManager: ConfigurationManager, - private readonly virtualDocumentFactory: VirtualDocumentProvider, - private readonly logger: Logger // tslint:disable-line - ) { - configurationManager.onUpdatedConfig(() => { - if (this._cssLanguageService) { - this._cssLanguageService.configure(this.configurationManager.config); - } - if (this._scssLanguageService) { - this._scssLanguageService.configure(this.configurationManager.config); - } - }); - } - - private get cssLanguageService(): LanguageService { - if (!this._cssLanguageService) { - this._cssLanguageService = getCSSLanguageService(); - this._cssLanguageService.configure(this.configurationManager.config); - } - return this._cssLanguageService; - } - - private get scssLanguageService(): LanguageService { - if (!this._scssLanguageService) { - this._scssLanguageService = getSCSSLanguageService(); - this._scssLanguageService.configure(this.configurationManager.config); - } - return this._scssLanguageService; - } - - public getCompletionsAtPosition( - context: TemplateContext, - position: ts.LineAndCharacter - ): ts.WithMetadata { - const items = this.getCompletionItems(context, position); - const doc = this.virtualDocumentFactory.createVirtualDocument(context); - const wrapper = this.virtualDocumentFactory.getVirtualDocumentWrapper(context); - return translateCompletionItemsToCompletionInfo(this.typescript, items, doc, wrapper); - } - - public getCompletionEntryDetails( - context: TemplateContext, - position: ts.LineAndCharacter, - name: string - ): ts.CompletionEntryDetails { - const item = this.getCompletionItems(context, position).items.find((x) => x.label === name); - if (!item) { - return { - name, - kind: this.typescript.ScriptElementKind.unknown, - kindModifiers: '', - tags: [], - displayParts: toDisplayParts(name), - documentation: [], - }; - } - return translateCompletionItemsToCompletionEntryDetails(this.typescript, item); - } - - public getQuickInfoAtPosition(context: TemplateContext, position: ts.LineAndCharacter): ts.QuickInfo | undefined { - const doc = this.virtualDocumentFactory.createVirtualDocument(context); - const stylesheet = this.scssLanguageService.parseStylesheet(doc); - const hover = this.scssLanguageService.doHover( - doc, - this.virtualDocumentFactory.toVirtualDocPosition(position), - stylesheet - ); - if (hover) { - return this.translateHover(hover, this.virtualDocumentFactory.toVirtualDocPosition(position), context); - } - return undefined; - } - - public getSemanticDiagnostics(context: TemplateContext): ts.Diagnostic[] { - const doc = this.virtualDocumentFactory.createVirtualDocument(context); - const stylesheet = this.scssLanguageService.parseStylesheet(doc); - return this.translateDiagnostics( - this.scssLanguageService.doValidation(doc, stylesheet), - doc, - context, - context.text - ).filter((x) => !!x) as ts.Diagnostic[]; - } - - public getSupportedCodeFixes(): number[] { - return [cssErrorCode]; - } - - public getCodeFixesAtPosition( - context: TemplateContext, - start: number, - end: number - // _errorCodes: number[], - // _format: ts.FormatCodeSettings - ): ts.CodeAction[] { - const doc = this.virtualDocumentFactory.createVirtualDocument(context); - const stylesheet = this.scssLanguageService.parseStylesheet(doc); - const range = this.toVsRange(context, start, end); - const diagnostics = this.scssLanguageService - .doValidation(doc, stylesheet) - .filter((diagnostic) => overlaps(diagnostic.range, range)); - - return this.translateCodeActions( - context, - this.scssLanguageService.doCodeActions(doc, range, { diagnostics }, stylesheet) - ); - } - - public getOutliningSpans(context: TemplateContext): ts.OutliningSpan[] { - const doc = this.virtualDocumentFactory.createVirtualDocument(context); - const ranges = this.scssLanguageService.getFoldingRanges(doc); - return ranges - .filter((range) => { - // Filter out ranges outside on last line - const end = context.toOffset({ - line: range.endLine, - character: range.endCharacter || 0, - }); - return end < context.text.length; - }) - .map((range) => this.translateOutliningSpan(context, range)); - } - - private toVsRange(context: TemplateContext, start: number, end: number): vscode.Range { - return { - start: this.virtualDocumentFactory.toVirtualDocPosition(context.toPosition(start)), - end: this.virtualDocumentFactory.toVirtualDocPosition(context.toPosition(end)), - }; - } - - private getCompletionItems(context: TemplateContext, position: ts.LineAndCharacter): vscode.CompletionList { - const cached = this._completionsCache.getCached(context, position); - const completions: vscode.CompletionList = { - isIncomplete: false, - items: [], - }; - - if (cached) { - return cached; - } - - /** - * This would happen if a ` is triggered causing VSCode to open up two ``. At this stage completions aren't needed - * but they are still requested. - * Due to the fact there's nothing to complete (empty template) the language servers below end up requesting everything, - * causing a 3-4 second delay. When a template string is opened up we should do nothing and return an empty list. - * - * Also fixes: https://github.com/styled-components/vscode-styled-components/issues/276 - **/ - if (context.node.getText() === '``') { - return completions; - } - - const doc = this.virtualDocumentFactory.createVirtualDocument(context); - const virtualPosition = this.virtualDocumentFactory.toVirtualDocPosition(position); - const stylesheet = this.scssLanguageService.parseStylesheet(doc); - this.cssLanguageService.setCompletionParticipants([]); - const emmetResults = - emmetDoComplete(doc, virtualPosition, 'css', this.configurationManager.config.emmet) || emptyCompletionList; - const completionsCss = - this.cssLanguageService.doComplete(doc, virtualPosition, stylesheet) || emptyCompletionList; - const completionsScss = - this.scssLanguageService.doComplete(doc, virtualPosition, stylesheet) || emptyCompletionList; - completionsScss.items = filterScssCompletionItems(completionsScss.items); - - completions.items = [...completionsCss.items, ...completionsScss.items]; - if (emmetResults.items.length) { - completions.items.push(...emmetResults.items); - completions.isIncomplete = true; - } - this._completionsCache.updateCached(context, position, completions); - return completions; - } - - private translateDiagnostics( - diagnostics: vscode.Diagnostic[], - doc: TextDocument, - context: TemplateContext, - content: string - ) { - const sourceFile = context.node.getSourceFile(); - return diagnostics.map((diag) => this.translateDiagnostic(diag, sourceFile, doc, context, content)); - } - - private translateDiagnostic( - diagnostic: vscode.Diagnostic, - file: ts.SourceFile, - doc: TextDocument, - context: TemplateContext, - content: string - ): ts.Diagnostic | undefined { - // Make sure returned error is within the real document - if ( - diagnostic.range.start.line === 0 || - diagnostic.range.start.line > doc.lineCount || - diagnostic.range.start.character >= content.length - ) { - return undefined; - } - - const start = context.toOffset(this.virtualDocumentFactory.fromVirtualDocPosition(diagnostic.range.start)); - const length = - context.toOffset(this.virtualDocumentFactory.fromVirtualDocPosition(diagnostic.range.end)) - start; - const code = typeof diagnostic.code === 'number' ? diagnostic.code : cssErrorCode; - return { - code, - messageText: diagnostic.message, - category: translateSeverity(this.typescript, diagnostic.severity), - file, - start, - length, - source: config.pluginName, - }; - } - - private translateHover(hover: vscode.Hover, position: ts.LineAndCharacter, context: TemplateContext): ts.QuickInfo { - const contents: ts.SymbolDisplayPart[] = []; - const convertPart = (hoverContents: typeof hover.contents) => { - if (typeof hoverContents === 'string') { - contents.push({ kind: 'unknown', text: hoverContents }); - } else if (Array.isArray(hoverContents)) { - hoverContents.forEach(convertPart); - } else { - contents.push({ kind: 'unknown', text: hoverContents.value }); - } - }; - convertPart(hover.contents); - const start = context.toOffset( - this.virtualDocumentFactory.fromVirtualDocPosition(hover.range ? hover.range.start : position) - ); - return { - kind: this.typescript.ScriptElementKind.unknown, - kindModifiers: '', - textSpan: { - start, - length: hover.range - ? context.toOffset(this.virtualDocumentFactory.fromVirtualDocPosition(hover.range.end)) - start - : 1, - }, - displayParts: [], - documentation: contents, - tags: [], - }; - } - - private translateCodeActions(context: TemplateContext, codeActions: vscode.Command[]): ts.CodeAction[] { - const actions: ts.CodeAction[] = []; - for (const vsAction of codeActions) { - if (vsAction.command !== '_css.applyCodeAction') { - continue; - } - - const edits = vsAction.arguments && (vsAction.arguments[2] as vscode.TextEdit[]); - if (edits) { - actions.push({ - description: vsAction.title, - changes: edits.map((edit) => this.translateTextEditToFileTextChange(context, edit)), - }); - } - } - return actions; - } - - private translateTextEditToFileTextChange(context: TemplateContext, textEdit: vscode.TextEdit): ts.FileTextChanges { - const start = context.toOffset(this.virtualDocumentFactory.fromVirtualDocPosition(textEdit.range.start)); - const end = context.toOffset(this.virtualDocumentFactory.fromVirtualDocPosition(textEdit.range.end)); - return { - fileName: context.fileName, - textChanges: [ - { - newText: textEdit.newText, - span: { - start, - length: end - start, - }, - }, - ], - }; - } - - private translateOutliningSpan(context: TemplateContext, range: FoldingRange): ts.OutliningSpan { - const startOffset = context.toOffset( - this.virtualDocumentFactory.fromVirtualDocPosition({ - line: range.startLine, - character: range.startCharacter || 0, - }) - ); - const endOffset = context.toOffset( - this.virtualDocumentFactory.fromVirtualDocPosition({ - line: range.endLine, - character: range.endCharacter || 0, - }) - ); - const span = { - start: startOffset, - length: endOffset - startOffset, - }; - - return { - autoCollapse: false, - kind: this.typescript.OutliningSpanKind.Code, - bannerText: '', - textSpan: span, - hintSpan: span, - }; - } -} - -function filterScssCompletionItems(items: vscode.CompletionItem[]): vscode.CompletionItem[] { - return items.filter((item) => item.kind === vscode.CompletionItemKind.Function && item.label.substr(0, 1) === ':'); -} - -function translateCompletionItemsToCompletionInfo( - typescript: typeof ts, - items: vscode.CompletionList, - doc: TextDocument, - wrapper: string -): ts.WithMetadata { - return { - metadata: { - isIncomplete: items.isIncomplete, - }, - isGlobalCompletion: false, - isMemberCompletion: false, - isNewIdentifierLocation: false, - entries: items.items.map((x) => translateCompetionEntry(typescript, x, doc, wrapper)), - }; -} - -function translateCompletionItemsToCompletionEntryDetails( - typescript: typeof ts, - item: vscode.CompletionItem -): ts.CompletionEntryDetails { - return { - name: item.label, - kind: item.kind ? translateCompletionItemKind(typescript, item.kind) : typescript.ScriptElementKind.unknown, - kindModifiers: getKindModifiers(item), - displayParts: toDisplayParts(item.detail), - documentation: toDisplayParts(item.documentation), - tags: [], - }; -} - -function translateCompetionEntry( - typescript: typeof ts, - item: vscode.CompletionItem, - doc: TextDocument, - wrapper: string -): ts.CompletionEntry { - return { - name: item.label, - kind: item.kind ? translateCompletionItemKind(typescript, item.kind) : typescript.ScriptElementKind.unknown, - kindModifiers: getKindModifiers(item), - sortText: item.sortText || item.label, - replacementSpan: { - // The correct offset for start seems to be the range.start minus the wrapper - start: doc.offsetAt((item as any).textEdit.range.start) - wrapper.length, - length: doc.offsetAt((item as any).textEdit.range.end) - doc.offsetAt((item as any).textEdit.range.start), - }, - }; -} - -function translateCompletionItemKind(typescript: typeof ts, kind: vscode.CompletionItemKind): ts.ScriptElementKind { - switch (kind) { - case vscode.CompletionItemKind.Method: - return typescript.ScriptElementKind.memberFunctionElement; - case vscode.CompletionItemKind.Function: - return typescript.ScriptElementKind.functionElement; - case vscode.CompletionItemKind.Constructor: - return typescript.ScriptElementKind.constructorImplementationElement; - case vscode.CompletionItemKind.Field: - case vscode.CompletionItemKind.Variable: - return typescript.ScriptElementKind.variableElement; - case vscode.CompletionItemKind.Class: - return typescript.ScriptElementKind.classElement; - case vscode.CompletionItemKind.Interface: - return typescript.ScriptElementKind.interfaceElement; - case vscode.CompletionItemKind.Module: - return typescript.ScriptElementKind.moduleElement; - case vscode.CompletionItemKind.Property: - return typescript.ScriptElementKind.memberVariableElement; - case vscode.CompletionItemKind.Unit: - case vscode.CompletionItemKind.Value: - return typescript.ScriptElementKind.constElement; - case vscode.CompletionItemKind.Enum: - return typescript.ScriptElementKind.enumElement; - case vscode.CompletionItemKind.Keyword: - return typescript.ScriptElementKind.keyword; - case vscode.CompletionItemKind.Color: - return typescript.ScriptElementKind.constElement; - case vscode.CompletionItemKind.Reference: - return typescript.ScriptElementKind.alias; - case vscode.CompletionItemKind.File: - return typescript.ScriptElementKind.moduleElement; - case vscode.CompletionItemKind.Snippet: - case vscode.CompletionItemKind.Text: - default: - return typescript.ScriptElementKind.unknown; - } -} - -function getKindModifiers(item: vscode.CompletionItem): string { - if (item.kind === vscode.CompletionItemKind.Color) { - return 'color'; - } - return ''; -} - -function translateSeverity( - typescript: typeof ts, - severity: vscode.DiagnosticSeverity | undefined -): ts.DiagnosticCategory { - switch (severity) { - case vscode.DiagnosticSeverity.Information: - case vscode.DiagnosticSeverity.Hint: - return typescript.DiagnosticCategory.Message; - - case vscode.DiagnosticSeverity.Warning: - return typescript.DiagnosticCategory.Warning; - - case vscode.DiagnosticSeverity.Error: - default: - return typescript.DiagnosticCategory.Error; - } -} - -function toDisplayParts(text: string | vscode.MarkupContent | undefined): ts.SymbolDisplayPart[] { - if (!text) { - return []; - } - return [ - { - kind: 'text', - text: typeof text === 'string' ? text : text.value, - }, - ]; -} diff --git a/src/_logger.ts b/src/_logger.ts deleted file mode 100644 index b3f7d02..0000000 --- a/src/_logger.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Logger } from 'typescript-template-language-service-decorator'; -import { pluginName } from './_config'; -import * as ts from 'typescript/lib/tsserverlibrary'; - -export class LanguageServiceLogger implements Logger { - constructor(private readonly info: ts.server.PluginCreateInfo) {} - - public log(msg: string) { - this.info.project.projectService.logger.info(`[${pluginName}] ${msg}`); - } -} diff --git a/src/_plugin.ts b/src/_plugin.ts deleted file mode 100644 index 422429a..0000000 --- a/src/_plugin.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -import { decorateWithTemplateLanguageService, TemplateSettings } from 'typescript-template-language-service-decorator'; -import * as ts from 'typescript/lib/tsserverlibrary'; -import { ConfigurationManager } from './_configuration'; -import { StyledTemplateLanguageService } from './_language-service'; -import { LanguageServiceLogger } from './_logger'; -import { getSubstitutions } from './_substituter'; -import { StyledVirtualDocumentFactory } from './_virtual-document-provider'; - -export class StyledPlugin { - private _logger?: LanguageServiceLogger; - private readonly _configManager = new ConfigurationManager(); - - public constructor(private readonly typescript: typeof ts) {} - - public create(info: ts.server.PluginCreateInfo): ts.LanguageService { - this._logger = new LanguageServiceLogger(info); - this._configManager.updateFromPluginConfig(info.config); - - this._logger.log('config: ' + JSON.stringify(this._configManager.config)); - - if (!isValidTypeScriptVersion(this.typescript)) { - this._logger.log('Invalid typescript version detected. TypeScript 3.x required.'); - return info.languageService; - } - - return decorateWithTemplateLanguageService( - this.typescript, - info.languageService, - info.project, - new StyledTemplateLanguageService( - this.typescript, - this._configManager, - new StyledVirtualDocumentFactory(), - this._logger - ), - getTemplateSettings(this._configManager), - { logger: this._logger } - ); - } - - public onConfigurationChanged(config: any) { - if (this._logger) { - this._logger.log('onConfigurationChanged'); - } - this._configManager.updateFromPluginConfig(config); - } -} - -export function getTemplateSettings(configManager: ConfigurationManager): TemplateSettings { - return { - get tags() { - return configManager.config.tags; - }, - enableForStringWithSubstitutions: true, - getSubstitutions(templateString, spans): string { - return getSubstitutions(templateString, spans); - }, - }; -} - -function isValidTypeScriptVersion(typescript: typeof ts): boolean { - const [major] = typescript.version.split('.'); - return +major >= 3; -} diff --git a/src/_substituter.ts b/src/_substituter.ts deleted file mode 100644 index cda196b..0000000 --- a/src/_substituter.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -export function getSubstitutions( - contents: string, - spans: ReadonlyArray<{ start: number, end: number }> -): string { - const parts: string[] = []; - let lastIndex = 0; - const lineStarts = contents - .split('\n') - .map(line => line.length) - .reduce((previousValue, currentValue, currentIndex) => [...previousValue, currentValue + previousValue[currentIndex] + 1], [0]); - let lineStartIndex = 0; - for (const span of spans) { - while (lineStarts[lineStartIndex] <= span.start) { - lineStartIndex++; - } - const preTillLineStart = contents.slice(lineStarts[lineStartIndex - 1], span.start); - const preTillLastIndex = contents.slice(lastIndex, span.start); - const post = contents.slice(span.end); - const placeholder = contents.slice(span.start, span.end); - - parts.push(preTillLastIndex); - parts.push(getSubstitution({ preTillLineStart, preTillLastIndex, placeholder, post })); - lastIndex = span.end; - } - parts.push(contents.slice(lastIndex)); - return parts.join(''); -} - -function getSubstitution( - context: { - placeholder: string, - preTillLineStart: string, - preTillLastIndex: string, - post: string - } -): string { - // Check to see if it's an in-property interplation, or a mixin, - // and determine which character to use in either case - // if in-property, replace with "xxxxxx" - // if a mixin, replace with " " - const replacementChar = getReplacementCharacter(context.preTillLineStart, context.preTillLastIndex, context.post); - const result = context.placeholder.replace(/./gm, c => c === '\n' ? '\n' : replacementChar); - - // If followed by a semicolon, we may have to eat the semi colon using a false property - if (replacementChar === ' ' && context.post.match(/^\s*;/)) { - // Handle case where we need to eat the semi colon: - // - // styled.x` - // ${'color: red'}; - // ` - // - // vs. the other case where we do not: - // - // styled.x` - // color: ${'red'}; - // ` - if (context.preTillLastIndex.match(/(;|^|\}|\{)[\s|\n]*$/)) { - // Mixin, replace with a dummy variable declaration, so scss server doesn't complain about rogue semicolon - return '$a:0' + result.slice(4); - } - return context.placeholder.replace(/./gm, c => c === '\n' ? '\n' : 'x'); - } - - // Placeholder used as property name: - // - // styled.x` - // ${'color'}: red; - // ` - // - // But note that this shouldn't be included: - // - // styled.x` - // ${'button'}:hover & { - // color: red - // } - // ` - // - // Replace with fake property name - if (context.post.match(/^\s*[:]/) && !context.post.match(/^\s*[:].+?[\{&]/)) { - return '$a' + result.slice(2); - } - - // Placeholder for component - // - // styled.x` - // ${'button'}:hover & { - // color: red - // } - // ` - // Replace with fake selector - if (context.post.match(/^\s*[:].+?[\{&]/)) { - return '&' + ' '.repeat(result.length - 1); - } - - // Placeholder used as hex value: - // - // styled.x` - // color: #${'000'}; - // ` - if (context.preTillLastIndex.match(/#\s*$/)) { - return '000' + ' '.repeat(Math.max(context.placeholder.length - 3, 0)); - } - - return result; -} - -function getReplacementCharacter( - preTillLineStart: string, - preTillLastIndex: string, - post: string -) { - const emptySpacesRegExp = /(^|\n)\s*$/g; - if (preTillLineStart.match(emptySpacesRegExp) && preTillLastIndex.match(emptySpacesRegExp)) { - if (!post.match(/^\s*[{:,]/)) { // ${'button'} { - return ' '; - } - } - - // If the placeholder looks like a unit that would not work when replaced with an identifier, - // try replaceing with a number. - if (post.match(/^%/)) { - return '0'; - } - - // Otherwise replace with an identifier - return 'x'; -} diff --git a/src/_virtual-document-provider.ts b/src/_virtual-document-provider.ts deleted file mode 100644 index 35a0a17..0000000 --- a/src/_virtual-document-provider.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -import { TemplateContext } from 'typescript-template-language-service-decorator'; -import { TextDocument, Position } from 'vscode-languageserver-textdocument'; -import * as ts from 'typescript/lib/tsserverlibrary'; - -/** - * Handles mapping between template contents to virtual documents. - */ -export interface VirtualDocumentProvider { - createVirtualDocument(context: TemplateContext): TextDocument; - toVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter; - fromVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter; - toVirtualDocOffset(offset: number, context: TemplateContext): number; - fromVirtualDocOffset(offset: number, context: TemplateContext): number; - getVirtualDocumentWrapper(context: TemplateContext): string; -} - -/** - * Standard virtual document provider for styled content. - * - * Wraps content in a top level `:root { }` rule to make css language service happy - * since styled allows properties to be top level elements. - */ -export class StyledVirtualDocumentFactory implements VirtualDocumentProvider { - private static readonly wrapperPreRoot = ':root{\n'; - private static readonly wrapperPreKeyframes = '@keyframes custom {\n'; - - public createVirtualDocument(context: TemplateContext): TextDocument { - const contents = `${this.getVirtualDocumentWrapper(context)}${context.text}\n}`; - return { - uri: 'untitled://embedded.scss', - languageId: 'scss', - version: 1, - getText: () => contents, - positionAt: (offset: number) => { - const pos = context.toPosition(this.fromVirtualDocOffset(offset, context)); - return this.toVirtualDocPosition(pos); - }, - offsetAt: (p: Position) => { - const offset = context.toOffset(this.fromVirtualDocPosition(p)); - return this.toVirtualDocOffset(offset, context); - }, - lineCount: contents.split(/\n/g).length + 1, - }; - } - - public toVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter { - return { - line: position.line + 1, - character: position.character, - }; - } - - public fromVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter { - return { - line: position.line - 1, - character: position.character, - }; - } - - public toVirtualDocOffset(offset: number, context: TemplateContext): number { - return offset + this.getVirtualDocumentWrapper(context).length; - } - - public fromVirtualDocOffset(offset: number, context: TemplateContext): number { - return offset - this.getVirtualDocumentWrapper(context).length; - } - - public getVirtualDocumentWrapper(context: TemplateContext): string { - const tag = (context.node.parent as ts.Node & { tag: any })?.tag?.escapedText; - return tag === 'keyframes' - ? StyledVirtualDocumentFactory.wrapperPreKeyframes - : StyledVirtualDocumentFactory.wrapperPreRoot; - } -} diff --git a/src/api.ts b/src/api.ts index 4dbe92c..d8a9bb3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,9 +1,23 @@ - // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// Public api that allows the language service to consumed by other libraries -export { StyledTemplateLanguageService } from './_language-service'; -export { StyledPluginConfiguration } from './_configuration'; -export { VirtualDocumentProvider } from './_virtual-document-provider'; -export { getTemplateSettings } from './_plugin'; \ No newline at end of file +/** Public API that lets other libraries consume the language service. */ +export { StyledTemplateLanguageService } from './template-language-service.ts' +export { + PluginConfigurationManager, + /** Alias matching the name 1.0.1 published for this class. */ + PluginConfigurationManager as ConfigurationManager, +} from './configuration/plugin-configuration.ts' +export type { + StyledPluginConfiguration, + StyledPluginConfigurationInput, + StyledPluginEmmetConfiguration, + StyledPluginLintConfiguration, + StyledPluginLintLevel, +} from './configuration/plugin-configuration.ts' +export type { + EmmetCompletionProvider, + StylesLanguageServiceFactory, +} from './features/styles-language-services.ts' +export type { VirtualDocumentProvider } from './virtual-document/styled-virtual-document-provider.ts' +export { getTemplateSettings } from './template-settings.ts' diff --git a/src/configuration/plugin-configuration.ts b/src/configuration/plugin-configuration.ts new file mode 100644 index 0000000..470a3e1 --- /dev/null +++ b/src/configuration/plugin-configuration.ts @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export type StyledPluginLintLevel = 'ignore' | 'warning' | 'error' + +export interface StyledPluginLintConfiguration { + readonly argumentsInColorFunction?: StyledPluginLintLevel + readonly boxModel?: StyledPluginLintLevel + readonly compatibleVendorPrefixes?: StyledPluginLintLevel + readonly duplicateProperties?: StyledPluginLintLevel + readonly emptyRules?: StyledPluginLintLevel + readonly float?: StyledPluginLintLevel + readonly fontFaceProperties?: StyledPluginLintLevel + readonly hexColorLength?: StyledPluginLintLevel + readonly idSelector?: StyledPluginLintLevel + readonly ieHack?: StyledPluginLintLevel + readonly importStatement?: StyledPluginLintLevel + readonly important?: StyledPluginLintLevel + readonly propertyIgnoredDueToDisplay?: StyledPluginLintLevel + readonly universalSelector?: StyledPluginLintLevel + readonly unknownAtRules?: StyledPluginLintLevel + readonly unknownProperties?: StyledPluginLintLevel + readonly unknownVendorSpecificProperties?: StyledPluginLintLevel + readonly validProperties?: ReadonlyArray + readonly vendorPrefix?: StyledPluginLintLevel + readonly zeroUnits?: StyledPluginLintLevel +} + +export interface StyledPluginEmmetConfiguration { + readonly excludeLanguages?: string[] + readonly preferences?: Readonly> + readonly showAbbreviationSuggestions?: boolean + readonly showExpandedAbbreviation?: string + readonly showSuggestionsAsSnippets?: boolean + readonly syntaxProfiles?: Readonly> + readonly variables?: Readonly> +} + +/** Partial plugin configuration for a caller building one in code, with closed `lint` and `emmet` shapes. */ +export interface StyledPluginConfigurationInput { + readonly emmet?: StyledPluginEmmetConfiguration + readonly lint?: StyledPluginLintConfiguration + readonly tags?: ReadonlyArray + readonly validate?: boolean +} + +/** + * Fully-resolved plugin configuration, every field always present, matching 1.0.1's published + * `StyledPluginConfiguration` (`lib/_configuration.d.ts`) exactly: `lint` and `emmet` are the + * open `{ [key: string]: any }` shape 1.0.1 declared, not the closed + * `StyledPluginLintConfiguration` / `StyledPluginEmmetConfiguration` interfaces above, so a + * consumer indexing an arbitrary key (a lint rule or Emmet option this plugin does not yet know + * about) keeps compiling the way it did against 1.0.1. + */ +export interface StyledPluginConfiguration { + readonly emmet: { [key: string]: any } + readonly lint: { [key: string]: any } + readonly tags: ReadonlyArray + readonly validate: boolean +} + +/** Tag name whose template is a style fragment (`css`), a value-shaped one included. */ +export const CSS_TAG_NAME = 'css' + +/** Tag name whose template is the body of a keyframes rule. */ +export const KEYFRAMES_TAG_NAME = 'keyframes' + +/** Tag names whose template's top level is the stylesheet's own top level, not a style rule body. */ +export const GLOBAL_STYLE_TAG_NAMES: ReadonlyArray = [ + 'createGlobalStyle', + 'globalCss', + 'injectGlobal', +] + +export class PluginConfigurationManager { + private static readonly defaultConfiguration: StyledPluginConfiguration = { + emmet: {}, + lint: { emptyRules: 'ignore' }, + tags: ['styled', CSS_TAG_NAME, KEYFRAMES_TAG_NAME, ...GLOBAL_STYLE_TAG_NAMES, 'extend'], + validate: true, + } + + private readonly updateListeners = new Set<() => void>() + private configuration: StyledPluginConfiguration = PluginConfigurationManager.defaultConfiguration + + public get config(): StyledPluginConfiguration { + return this.configuration + } + + /** + * Replaces the whole configuration with `config` normalized (docs/architecture.md, + * Configuration): a rejected value falls back to its default and is reported through `logger`, + * one message per value. + */ + public updateFromPluginConfig(config: unknown, logger?: ConfigurationLogger) { + const reporter = createReporter(logger) + const defaults = PluginConfigurationManager.defaultConfiguration + let settings: Readonly> = {} + if (isRecord(config)) { + settings = config + } else if (config !== undefined && config !== null) { + reporter.report( + `Ignored the plugin configuration: it accepts an object of plugin settings; received ${describeValue(config)}. The defaults apply.`, + ) + } + + this.configuration = { + emmet: + normalizeRecord(settings.emmet, 'emmet', 'an object of Emmet options', reporter) ?? + defaults.emmet, + lint: { + ...defaults.lint, + ...normalizeLint( + normalizeRecord(settings.lint, 'lint', 'an object of lint settings', reporter), + defaults.lint, + reporter, + ), + }, + tags: normalizeTags(settings.tags, reporter) ?? defaults.tags, + validate: normalizeBoolean(settings.validate, 'validate', reporter) ?? defaults.validate, + } + reportUnknownSettings(settings, reporter) + + for (const listener of this.updateListeners) { + listener() + } + } + + public onUpdatedConfig(listener: () => void) { + this.updateListeners.add(listener) + } +} + +/** Receives one message per rejected configuration value; the tsserver log in the plugin. */ +export interface ConfigurationLogger { + log(message: string): void +} + +interface Reporter { + report(message: string): void + /** Logs one rejected setting value; `defaultNote` names what applies instead. */ + reportRejected(name: string, accepted: string, value: unknown, defaultNote?: string): void +} + +const VALID_PROPERTIES_SETTING = 'validProperties' + +/** + * Lint settings whose value is a level, keyed so the compiler rejects a level setting added to + * StyledPluginLintConfiguration but missing here. Every other lint key (validProperties, or a + * rule this plugin does not know yet) passes through unchanged, so a newer + * vscode-css-languageservice rule keeps working. + */ +const LINT_LEVEL_SETTINGS: Readonly< + Record, true> +> = { + argumentsInColorFunction: true, + boxModel: true, + compatibleVendorPrefixes: true, + duplicateProperties: true, + emptyRules: true, + float: true, + fontFaceProperties: true, + hexColorLength: true, + idSelector: true, + ieHack: true, + importStatement: true, + important: true, + propertyIgnoredDueToDisplay: true, + universalSelector: true, + unknownAtRules: true, + unknownProperties: true, + unknownVendorSpecificProperties: true, + vendorPrefix: true, + zeroUnits: true, +} + +/** + * Each accepted lint level spelling, lowercase, and the level it names: the levels themselves, + * plus spellings from other linters whose meaning is unambiguous (`off`, `warn`). + */ +const LINT_LEVEL_SPELLINGS: Readonly> = { + error: 'error', + ignore: 'ignore', + off: 'ignore', + warn: 'warning', + warning: 'warning', +} + +const BOOLEAN_STRINGS: Readonly> = { false: false, true: true } + +/** The top-level settings the plugin reads, keyed so the compiler rejects one missing here. */ +const PLUGIN_SETTINGS: Readonly> = { + emmet: true, + lint: true, + tags: true, + validate: true, +} + +const PLUGIN_SETTING_NAMES = Object.keys(PLUGIN_SETTINGS).sort() + +/** `a`, `a and b`, or `a, b, and c`. */ +const LIST_FORMAT = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }) + +/** + * Keys tsserver adds to the configuration it passes a plugin, which are not plugin settings: + * `name` from the tsconfig `plugins` entry, and `global` for a plugin an editor loads globally + * (docs/tsserver-host.md). + */ +const TSSERVER_PLUGIN_ENTRY_KEYS: ReadonlySet = new Set(['global', 'name']) + +/** A misspelled setting name otherwise leaves its setting at the default with no sign of why. */ +function reportUnknownSettings(settings: Readonly>, reporter: Reporter) { + for (const name of Object.keys(settings)) { + if (hasOwn(PLUGIN_SETTINGS, name) || TSSERVER_PLUGIN_ENTRY_KEYS.has(name)) { + continue + } + const suggestion = findClosestSettingName(name) + reporter.report( + `Ignored the plugin setting ${name}: the plugin has no setting with that name.${ + suggestion ? ` Did you mean ${suggestion}?` : '' + } The plugin settings are ${LIST_FORMAT.format(PLUGIN_SETTING_NAMES)}.`, + ) + } +} + +/** The setting name a misspelled `name` most likely meant (docs/architecture.md, Configuration), if any. */ +function findClosestSettingName(name: string): string | undefined { + const maximumDistance = Math.max(1, Math.floor(name.length * 0.4)) + const lowercaseName = name.toLowerCase() + let closest: string | undefined + let closestDistance = maximumDistance + 1 + for (const candidate of PLUGIN_SETTING_NAMES) { + const distance = editDistance(lowercaseName, candidate.toLowerCase()) + if (distance < closestDistance) { + closest = candidate + closestDistance = distance + } + } + return closest +} + +/** The Levenshtein distance: the fewest single-character insertions, deletions, and substitutions. */ +function editDistance(left: string, right: string): number { + let previousRow = Array.from({ length: right.length + 1 }, (_, index) => index) + for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) { + const row = [leftIndex] + for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) { + const substitution = + previousRow[rightIndex - 1] + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1) + row.push(Math.min(previousRow[rightIndex] + 1, row[rightIndex - 1] + 1, substitution)) + } + previousRow = row + } + return previousRow[right.length] +} + +/** `Object.hasOwn` is missing on the Node 14 runtime floor (docs/tsserver-host.md). */ +function hasOwn(record: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key) +} + +function normalizeLint( + lint: Readonly> | undefined, + defaults: Readonly>, + reporter: Reporter, +): Record { + const normalized: Record = {} + for (const [name, value] of Object.entries(lint ?? {})) { + if (name === VALID_PROPERTIES_SETTING) { + const validProperties = normalizeValidProperties(value, reporter) + if (validProperties) { + normalized[name] = validProperties + } + continue + } + if (!hasOwn(LINT_LEVEL_SETTINGS, name)) { + normalized[name] = value + continue + } + const level = typeof value === 'string' ? toLintLevel(value) : undefined + if (level) { + normalized[name] = level + continue + } + const pluginDefault = hasOwn(defaults, name) ? defaults[name] : undefined + reporter.reportRejected( + `lint.${name}`, + '"ignore", "warning", or "error"', + value, + typeof pluginDefault === 'string' + ? `The plugin default, "${pluginDefault}", applies.` + : "The CSS language service's own default for this rule applies.", + ) + } + return normalized +} + +/** A lint level spelling in any letter case, as the level it names. */ +function toLintLevel(value: string): StyledPluginLintLevel | undefined { + const lowercase = value.toLowerCase() + return hasOwn(LINT_LEVEL_SPELLINGS, lowercase) ? LINT_LEVEL_SPELLINGS[lowercase] : undefined +} + +function normalizeValidProperties( + value: unknown, + reporter: Reporter, +): ReadonlyArray | undefined { + if (typeof value === 'string') { + return [value] + } + if (!Array.isArray(value)) { + reporter.reportRejected( + `lint.${VALID_PROPERTIES_SETTING}`, + 'a list of property names, such as ["margin-vertical"]', + value, + ) + return undefined + } + const names: string[] = [] + const rejected: unknown[] = [] + for (const item of value) { + if (typeof item === 'string') { + names.push(item) + } else { + rejected.push(item) + } + } + if (rejected.length) { + reporter.report( + `Ignored part of the plugin setting lint.${VALID_PROPERTIES_SETTING}: it accepts a list of property names; received the entries ${LIST_FORMAT.format(rejected.map(describeValue))}, which are not strings.`, + ) + } + return names +} + +function normalizeTags(value: unknown, reporter: Reporter): ReadonlyArray | undefined { + if (typeof value === 'string') { + return [value] + } + if (Array.isArray(value) && value.every((item): item is string => typeof item === 'string')) { + return value + } + if (value !== undefined) { + reporter.reportRejected('tags', 'a list of tag names, such as ["styled", "css"]', value) + } + return undefined +} + +function normalizeBoolean(value: unknown, name: string, reporter: Reporter): boolean | undefined { + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string' && hasOwn(BOOLEAN_STRINGS, value)) { + return BOOLEAN_STRINGS[value] + } + if (value !== undefined) { + reporter.reportRejected(name, 'true or false', value) + } + return undefined +} + +function normalizeRecord( + value: unknown, + name: string, + accepted: string, + reporter: Reporter, +): Readonly> | undefined { + if (isRecord(value)) { + return value + } + if (value !== undefined) { + reporter.reportRejected(name, accepted, value) + } + return undefined +} + +function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Configuration arrives from tsserver as parsed JSON, but `./api` callers can pass anything, so + * describing a value never throws: a value JSON cannot represent (a bigint, a cycle) falls back + * to String, and one whose conversion throws is described generically. + */ +export function describeValue(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value) + } catch { + try { + return String(value) + } catch { + return 'a value that cannot be displayed' + } + } +} + +/** + * A failing logger must not stop the configuration from applying: normalization runs inside + * tsserver's plugin creation and configurePlugin handling, where a throw fails the request. + */ +function createReporter(logger: ConfigurationLogger | undefined): Reporter { + const report = (message: string) => { + try { + logger?.log(message) + } catch { + /** Logging is best effort; the normalized configuration is what matters. */ + } + } + return { + report, + reportRejected(name, accepted, value, defaultNote = 'The default applies.') { + report( + `Ignored the plugin setting ${name}: it accepts ${accepted}; received ${describeValue(value)}. ${defaultNote}`, + ) + }, + } +} diff --git a/src/features/code-actions.ts b/src/features/code-actions.ts new file mode 100644 index 0000000..f090c2d --- /dev/null +++ b/src/features/code-actions.ts @@ -0,0 +1,119 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' +import type { Command, Range } from 'vscode-css-languageservice' +import { TextEdit } from 'vscode-languageserver-types' + +import type { VirtualDocumentProvider } from '../virtual-document/styled-virtual-document-provider.ts' +import { + fromVirtualDocSpanStrict, + templateOffsetToPosition, + type TemplateLineMap, + type TemplateSpan, +} from '../virtual-document/template-line-map.ts' +import type { VirtualDocumentSessionProvider } from '../virtual-document/virtual-document-session-provider.ts' +import { CSS_DIAGNOSTIC_CODE } from './css-diagnostic-code.ts' +import type { DiagnosticsFeature } from './diagnostics.ts' +import type { ScssLanguageService } from './styles-language-services.ts' + +/** The command vscode-css-languageservice wraps every quick fix in (cssCodeActions.js). */ +export const CSS_APPLY_CODE_ACTION_COMMAND = '_css.applyCodeAction' + +export class CodeActionsFeature { + public constructor( + private readonly virtualDocumentProvider: VirtualDocumentProvider, + private readonly virtualDocumentSessionProvider: VirtualDocumentSessionProvider, + private readonly scssLanguageService: ScssLanguageService, + private readonly diagnosticsFeature: DiagnosticsFeature, + ) {} + + public getCodeFixesAtPosition( + context: TemplateContext, + start: number, + end: number, + errorCodes: readonly number[] = [CSS_DIAGNOSTIC_CODE], + ): ts.CodeAction[] { + if (!errorCodes.includes(CSS_DIAGNOSTIC_CODE)) { + return [] + } + + const request: TemplateSpan = { end, start } + const diagnostics = this.diagnosticsFeature + .getShownDiagnostics(context) + .filter((shown) => isAskedAbout(shown, request)) + .map((shown) => shown.diagnostic) + if (diagnostics.length === 0) { + return [] + } + + const { document, lineMap, stylesheet } = + this.virtualDocumentSessionProvider.getParsedDocument(context) + const commands = this.scssLanguageService.doCodeActions( + document, + this.toVirtualRange(request, lineMap), + { diagnostics }, + stylesheet, + ) + return this.translateCodeActions(context, commands, lineMap) + } + + private toVirtualRange({ end, start }: TemplateSpan, lineMap: TemplateLineMap): Range { + return { + end: this.virtualDocumentProvider.toVirtualDocPosition( + templateOffsetToPosition(end, lineMap), + ), + start: this.virtualDocumentProvider.toVirtualDocPosition( + templateOffsetToPosition(start, lineMap), + ), + } + } + + private translateCodeActions( + context: TemplateContext, + commands: readonly Command[], + lineMap: TemplateLineMap, + ): ts.CodeAction[] { + const actions: ts.CodeAction[] = [] + for (const command of commands) { + if (command.command !== CSS_APPLY_CODE_ACTION_COMMAND) { + continue + } + /** The edits are the command's third argument (cssCodeActions.js), typed only as `LSPAny`. */ + const edits: unknown = command.arguments?.[2] + if (!Array.isArray(edits) || !edits.every(TextEdit.is)) { + continue + } + const changes = edits + .map((edit) => this.translateEdit(context, edit, lineMap)) + .filter((change) => change !== undefined) + if (changes.length === edits.length) { + actions.push({ changes, description: command.title }) + } + } + return actions + } + + private translateEdit( + context: TemplateContext, + edit: TextEdit, + lineMap: TemplateLineMap, + ): ts.FileTextChanges | undefined { + const span = fromVirtualDocSpanStrict(this.virtualDocumentProvider, edit.range, lineMap) + if (!span) { + return undefined + } + return { + fileName: context.fileName, + textChanges: [ + { newText: edit.newText, span: { length: span.end - span.start, start: span.start } }, + ], + } + } +} + +/** The rule for which diagnostics a request asks about: docs/architecture.md, "Code fixes". */ +function isAskedAbout(diagnostic: TemplateSpan, request: TemplateSpan): boolean { + if (request.start === request.end) { + return diagnostic.start <= request.start && request.start < diagnostic.end + } + return diagnostic.start < request.end && request.start < diagnostic.end +} diff --git a/src/features/completions.ts b/src/features/completions.ts new file mode 100644 index 0000000..2aa8ee8 --- /dev/null +++ b/src/features/completions.ts @@ -0,0 +1,598 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' +import type { CompletionItem, CompletionList, MarkupContent } from 'vscode-css-languageservice' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { CompletionItemKind } from 'vscode-languageserver-types' + +import type { StyledPluginConfiguration } from '../configuration/plugin-configuration.ts' +import { + createCodeLookback, + createCssCodeScanState, + INSIDE_NON_CODE, + isNameCharacter, + isStatementBoundary, + isWhitespace, + NO_SIGNIFICANT_CHARACTER, + nonCodeEnd, +} from '../virtual-document/css-code-scanner.ts' +import type { EscapeRun } from '../virtual-document/javascript-escapes.ts' +import { + StyledVirtualDocumentProvider, + type VirtualDocumentProvider, +} from '../virtual-document/styled-virtual-document-provider.ts' +import { + positionsEqual, + type TemplateLineMap, + widenToEscapeRuns, +} from '../virtual-document/template-line-map.ts' +import type { VirtualDocumentSessionProvider } from '../virtual-document/virtual-document-session-provider.ts' +import { markupText } from './markup-text.ts' +import type { + CssLanguageService, + EmmetCompletionProvider, + ScssLanguageService, +} from './styles-language-services.ts' + +/** + * A fresh object per call, never a shared module-level constant: StyledTemplateLanguageService + * is also the public ./api surface, so a third-party consumer holding and mutating a returned + * CompletionInfo (appending its own entries, for example) must not corrupt every other empty- + * template completion result for the rest of the process. + */ +export function createEmptyCompletionInfo(): ts.WithMetadata { + return { + entries: [], + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + metadata: { isIncomplete: false }, + } +} + +/** The details of an entry the completion list does not hold: its name alone. */ +export function createUnknownCompletionEntryDetails( + typescript: typeof ts, + name: string, +): ts.CompletionEntryDetails { + return { + displayParts: toDisplayParts(name), + documentation: [], + kind: typescript.ScriptElementKind.unknown, + kindModifiers: '', + name, + tags: [], + } +} + +interface CompletionResult { + readonly document: TextDocument + readonly lineMap: TemplateLineMap + readonly list: CompletionList +} + +export class CompletionsFeature { + private readonly cache: CompletionsCache + private nestedAtRuleCatalog?: readonly CompletionItem[] + + public constructor( + private readonly typescript: typeof ts, + private readonly virtualDocumentProvider: VirtualDocumentProvider, + private readonly virtualDocumentSessionProvider: VirtualDocumentSessionProvider, + private readonly cssLanguageService: CssLanguageService, + private readonly scssLanguageService: ScssLanguageService, + private readonly emmetCompletionProvider: EmmetCompletionProvider, + private readonly getConfiguration: () => StyledPluginConfiguration, + ) { + this.cache = new CompletionsCache(virtualDocumentProvider) + } + + public clearCache() { + this.cache.clear() + } + + public getCompletionsAtPosition( + context: TemplateContext, + position: ts.LineAndCharacter, + ): ts.WithMetadata { + const entry = this.getCompletionEntry(context, position) + if (!entry) { + return createEmptyCompletionInfo() + } + entry.info ??= translateCompletionListToCompletionInfo( + this.typescript, + this.virtualDocumentProvider, + context, + entry.result, + ) + return cloneCompletionInfo(entry.info) + } + + public getCompletionEntryDetails( + context: TemplateContext, + position: ts.LineAndCharacter, + name: string, + ): ts.CompletionEntryDetails { + const item = this.getCompletionEntry(context, position)?.result.list.items.find( + (candidate) => candidate.label === name, + ) + return item + ? translateCompletionItemToCompletionEntryDetails(this.typescript, item) + : createUnknownCompletionEntryDetails(this.typescript, name) + } + + private getCompletionEntry( + context: TemplateContext, + position: ts.LineAndCharacter, + ): CompletionsCacheEntry | undefined { + const cached = this.cache.get(context, position) + if (cached) { + return cached + } + const result = this.computeCompletions(context, position) + return result && this.cache.set(context, position, result) + } + + private computeCompletions( + context: TemplateContext, + position: ts.LineAndCharacter, + ): CompletionResult | undefined { + const { document, lineMap, stylesheet } = + this.virtualDocumentSessionProvider.getParsedDocument(context) + const virtualPosition = this.virtualDocumentProvider.toVirtualDocPosition(position) + const configuration = this.getConfiguration() + const emmetItems = + this.emmetCompletionProvider.doComplete(document, virtualPosition, configuration.emmet) + ?.items ?? [] + const text = document.getText() + const caretOffset = document.offsetAt(virtualPosition) + const caretPlacement = findCaretPlacement( + text, + caretOffset, + emmetItems.some(expandsToDeclaration), + ) + if (caretPlacement === CaretPlacement.NonCode) { + return undefined + } + + const items = [ + ...this.cssLanguageService.doComplete(document, virtualPosition, stylesheet).items, + ...filterScssCompletionItems( + this.scssLanguageService.doComplete(document, virtualPosition, stylesheet).items, + ), + ] + const atKeywordRange = findStatementAtKeywordRange( + text, + caretOffset, + this.virtualDocumentProvider.toVirtualDocOffset(0, context), + ) + if (atKeywordRange) { + const labels = new Set(items.map((item) => item.label)) + const range = { + end: document.positionAt(atKeywordRange.end), + start: document.positionAt(atKeywordRange.start), + } + for (const atRule of this.getNestedAtRuleCatalog()) { + if (!labels.has(atRule.label)) { + items.push({ ...atRule, textEdit: { newText: atRule.label, range } }) + } + } + } + const placedEmmetItems = + caretPlacement === CaretPlacement.NoDeclaration + ? emmetItems.filter((item) => !expandsToDeclaration(item)) + : emmetItems + items.push(...placedEmmetItems) + return { document, lineMap, list: { isIncomplete: placedEmmetItems.length > 0, items } } + } + + /** + * The CSS language service's own top-level at-rule completions for the names in + * NESTED_AT_RULE_NAMES (docs/architecture.md, Completions), requested on first use: the at-rule + * data is fixed for the life of the service and independent of configuration. + */ + private getNestedAtRuleCatalog(): readonly CompletionItem[] { + if (!this.nestedAtRuleCatalog) { + const document = TextDocument.create( + 'untitled://nested-at-rules.css', + 'css', + 1, + AT_KEYWORD_START, + ) + this.nestedAtRuleCatalog = this.cssLanguageService + .doComplete( + document, + document.positionAt(AT_KEYWORD_START.length), + this.scssLanguageService.parseStylesheet(document), + ) + .items.filter((item) => NESTED_AT_RULE_NAMES.has(item.label)) + .map(({ documentation, kind, label }) => ({ documentation, kind, label })) + } + return this.nestedAtRuleCatalog + } +} + +const AT_KEYWORD_START = '@' + +/** + * At-rules valid directly inside a style rule, plus the ones styled-components hoists out of a + * component to the stylesheet's top level that the CSS language service also completes at the + * top level (docs/architecture.md, Completions). + */ +const NESTED_AT_RULE_NAMES: ReadonlySet = new Set([ + '@container', + '@counter-style', + '@font-face', + '@font-palette-values', + '@keyframes', + '@layer', + '@media', + '@page', + '@property', + '@scope', + '@starting-style', + '@supports', +]) + +/** + * The [start, end) offsets of the at-keyword around `caretOffset` in the virtual document text + * (the "@" through the last name character after the caret), when that keyword is code and starts + * a statement: the nearest significant character before it, at or after `templateStart` and past + * comments (createCodeLookback), is ";", "{", or "}", or there is none (the template start). Only a + * caret on an at-keyword pays for the lookback, one forward pass from the template start. + */ +function findStatementAtKeywordRange( + text: string, + caretOffset: number, + templateStart: number, +): { end: number; start: number } | undefined { + let start = caretOffset + while (start > templateStart && isNameCharacter(text[start - 1])) { + start-- + } + if (start <= templateStart || text[start - 1] !== AT_KEYWORD_START) { + return undefined + } + start-- + + const before = createCodeLookback(text, templateStart).previousSignificant(start) + if (before === INSIDE_NON_CODE) { + return undefined + } + if (before !== NO_SIGNIFICANT_CHARACTER && !isStatementBoundary(text[before])) { + return undefined + } + + let end = caretOffset + while (end < text.length && isNameCharacter(text[end])) { + end++ + } + return { end, start } +} + +const CaretPlacement = { + NoDeclaration: 'no-declaration', + NonCode: 'non-code', + Other: 'other', +} as const + +type CaretPlacement = (typeof CaretPlacement)[keyof typeof CaretPlacement] + +/** + * Classifies the caret in the virtual document text (docs/architecture.md, Completions): NonCode + * inside a comment or string, the end of an unterminated one included; NoDeclaration where no + * declaration can start, which is when the last structural code character before the caret (";", + * "{", "}", or ":", the wrapper included) is ":" (a value, a selector with a pseudo-class, or a + * media feature), or when the statement it ends starts with an at-keyword (an at-rule prelude); + * Other everywhere else. The declaration test runs only when `checkDeclarationStart` is set (an + * Emmet item expands to a declaration). It first tries the nearest structural character before the + * caret, which is code in the common case, and walks every structural character from the start + * only when that one lies in a comment or string (a lower lookback query restarts the scan); + * queries otherwise only move forward, so the classification costs at most two passes over the + * text before the caret, plus the whitespace and comments that open the caret's statement. + */ +function findCaretPlacement( + text: string, + caretOffset: number, + checkDeclarationStart: boolean, +): CaretPlacement { + const lookback = createCodeLookback(text, 0) + const isCodeAt = (index: number) => lookback.previousSignificant(index + 1) === index + let lastStructuralIndex = -1 + if (checkDeclarationStart) { + let nearest = caretOffset - 1 + while (nearest >= 0 && !isStructuralCharacter(text[nearest])) { + nearest-- + } + if (nearest >= 0 && isCodeAt(nearest)) { + lastStructuralIndex = nearest + } else if (nearest >= 0) { + for (let index = 0; index < nearest; index++) { + if (isStructuralCharacter(text[index]) && isCodeAt(index)) { + lastStructuralIndex = index + } + } + } + } + if (lookback.previousSignificant(caretOffset) === INSIDE_NON_CODE) { + return CaretPlacement.NonCode + } + if (!checkDeclarationStart) { + return CaretPlacement.Other + } + return text[lastStructuralIndex] === ':' || + startsWithAtKeyword(text, lastStructuralIndex + 1, caretOffset) + ? CaretPlacement.NoDeclaration + : CaretPlacement.Other +} + +/** + * True when the first code character in [from, to), past whitespace and comments, is "@": the + * statement there is an at-rule prelude. + */ +function startsWithAtKeyword(text: string, from: number, to: number): boolean { + const state = createCssCodeScanState() + let index = from + while (index < to) { + const character = text[index] + if (isWhitespace(character)) { + index++ + continue + } + const commentEnd = character === '/' ? nonCodeEnd(text, index, state) : -1 + if (commentEnd === -1) { + return character === AT_KEYWORD_START + } + index = commentEnd + } + return false +} + +/** + * An Emmet expansion that is a whole declaration (`float: left;`) rather than a value (`#121212`, + * `!important`). Emmet labels an item with its expanded text, so the property's ":" marks it. + */ +function expandsToDeclaration(item: CompletionItem): boolean { + return item.label.includes(':') +} + +function isStructuralCharacter(character: string | undefined): boolean { + return isStatementBoundary(character) || character === ':' +} + +/** + * The last computed completions, reused for a request at the same position when + * canReuseVirtualDocument holds for the context that computed them: every value the translated + * CompletionInfo holds (replacement spans, the template-end bound) is a pure function of what that + * rule proves equal, so `info` stays correct for a different, reusable context too. + */ +interface CompletionsCacheEntry { + readonly context: TemplateContext + info?: ts.WithMetadata + readonly position: ts.LineAndCharacter + readonly result: CompletionResult +} + +class CompletionsCache { + private entry?: CompletionsCacheEntry + + public constructor(private readonly virtualDocumentProvider: VirtualDocumentProvider) {} + + public get( + context: TemplateContext, + position: ts.LineAndCharacter, + ): CompletionsCacheEntry | undefined { + const { entry } = this + return entry && + positionsEqual(position, entry.position) && + (this.virtualDocumentProvider.canReuseVirtualDocument?.(entry.context, context) ?? false) + ? entry + : undefined + } + + public set( + context: TemplateContext, + position: ts.LineAndCharacter, + result: CompletionResult, + ): CompletionsCacheEntry { + this.entry = { context, position, result } + return this.entry + } + + public clear() { + this.entry = undefined + } +} + +/** + * Returns a shallow copy of a translated CompletionInfo, with a fresh entries array of + * shallow-copied entry objects (and, for an entry that has one, a shallow-copied + * replacementSpan): CompletionsCache returns the same underlying CompletionInfo to every request + * at the same position (docs/architecture.md, "Caching"), so every caller, cached or not, gets + * its own object graph it can freely mutate (appending an entry, renaming one, sorting the list) + * without corrupting the cached original or a later caller's result. ./api exposes + * StyledTemplateLanguageService directly to third-party consumers who may reasonably do exactly + * that; typescript-template-language-service-decorator's own translateCompletionInfo and + * translateCompletionEntry already shallow-copy the info and every entry before returning them to + * a tsserver caller, so this only matters for a direct ./api caller (node_modules/typescript- + * template-language-service-decorator/lib/template-language-service-decorator.js). + */ +function cloneCompletionInfo( + info: ts.WithMetadata, +): ts.WithMetadata { + return { + ...info, + entries: info.entries.map((entry) => ({ + ...entry, + ...(entry.replacementSpan ? { replacementSpan: { ...entry.replacementSpan } } : {}), + })), + metadata: info.metadata ? { ...info.metadata } : info.metadata, + } +} + +function filterScssCompletionItems(items: readonly CompletionItem[]): CompletionItem[] { + return items.filter( + (item) => item.kind === CompletionItemKind.Function && item.label.startsWith(':'), + ) +} + +/** Per-list values every entry's translation reads: the template's virtual-document offsets and escape runs. */ +interface TemplateBounds { + readonly end: number + readonly escapeRuns: readonly EscapeRun[] + readonly start: number + toTemplateOffset(virtualOffset: number): number +} + +function translateCompletionListToCompletionInfo( + typescript: typeof ts, + virtualDocumentProvider: VirtualDocumentProvider, + context: TemplateContext, + { document, lineMap, list }: CompletionResult, +): ts.WithMetadata { + const start = virtualDocumentProvider.toVirtualDocOffset(0, context) + /** + * The built-in provider maps offsets by a fixed shift, so each entry subtracts it; any other + * provider keeps its own mapping, which the public contract does not require to be a shift. + */ + const isShiftMapped = virtualDocumentProvider instanceof StyledVirtualDocumentProvider + const template: TemplateBounds = { + end: isShiftMapped + ? start + context.rawText.length + : virtualDocumentProvider.toVirtualDocOffset(context.rawText.length, context), + escapeRuns: lineMap.escapeRuns, + start, + toTemplateOffset: isShiftMapped + ? (offset) => offset - start + : (offset) => virtualDocumentProvider.fromVirtualDocOffset(offset, context), + } + const entries: ts.CompletionEntry[] = [] + for (const item of list.items) { + const entry = translateCompletionEntry({ document, item, template, typescript }) + if (entry) { + entries.push(entry) + } + } + + return { + entries, + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + metadata: { isIncomplete: list.isIncomplete }, + } +} + +function translateCompletionItemToCompletionEntryDetails( + typescript: typeof ts, + item: CompletionItem, +): ts.CompletionEntryDetails { + return { + displayParts: toDisplayParts(item.detail), + documentation: toDisplayParts(item.documentation), + kind: translateCompletionItemKind(typescript, item.kind), + kindModifiers: getKindModifiers(item), + name: item.label, + tags: [], + } +} + +interface CompletionEntryTranslation { + readonly document: TextDocument + readonly item: CompletionItem + readonly template: TemplateBounds + readonly typescript: typeof ts +} + +/** + * Never sets insertText or isSnippet, matching 1.0.1's translateCompetionEntry exactly (lib/ + * _language-service.js), which built an entry from name/kind/kindModifiers/sortText/ + * replacementSpan alone and left insertion to the client's own handling of an entry's name. A + * snippet-format item's insertText (for example `border: ${1:1px} ${2:solid} ${3:black};$0`) is + * only safe for a client that renders its tab stops, and tsserver's + * includeCompletionsWithSnippetText preference, the only signal for that, is not trustworthy: + * VS Code's TypeScript extension always sends it as true regardless of whether the actual editor + * surface renders snippets, so honoring it would insert raw tab-stop syntax into every VS Code + * user's buffer instead of the entry's name. + */ +function translateCompletionEntry({ + document, + item, + template, + typescript, +}: CompletionEntryTranslation): ts.CompletionEntry | undefined { + const textEdit = item.textEdit + const range = textEdit && ('range' in textEdit ? textEdit.range : textEdit.replace) + const start = range ? document.offsetAt(range.start) : 0 + const end = range ? document.offsetAt(range.end) : 0 + if (range && (start < template.start || end < start || end > template.end)) { + return undefined + } + + const entry: ts.CompletionEntry = { + kind: translateCompletionItemKind(typescript, item.kind), + kindModifiers: getKindModifiers(item), + name: item.label, + sortText: item.sortText || item.label, + } + if (range) { + const source = widenToEscapeRuns( + template.toTemplateOffset(start), + template.toTemplateOffset(end), + template.escapeRuns, + ) + entry.replacementSpan = { + length: source.end - source.start, + start: source.start, + } + } + return entry +} + +function translateCompletionItemKind( + typescript: typeof ts, + kind: CompletionItemKind | undefined, +): ts.ScriptElementKind { + switch (kind) { + case CompletionItemKind.Method: + return typescript.ScriptElementKind.memberFunctionElement + case CompletionItemKind.Function: + return typescript.ScriptElementKind.functionElement + case CompletionItemKind.Constructor: + return typescript.ScriptElementKind.constructorImplementationElement + case CompletionItemKind.Field: + case CompletionItemKind.Variable: + return typescript.ScriptElementKind.variableElement + case CompletionItemKind.Class: + return typescript.ScriptElementKind.classElement + case CompletionItemKind.Interface: + return typescript.ScriptElementKind.interfaceElement + case CompletionItemKind.Module: + return typescript.ScriptElementKind.moduleElement + case CompletionItemKind.Property: + return typescript.ScriptElementKind.memberVariableElement + case CompletionItemKind.Unit: + case CompletionItemKind.Value: + case CompletionItemKind.Color: + return typescript.ScriptElementKind.constElement + case CompletionItemKind.Enum: + return typescript.ScriptElementKind.enumElement + case CompletionItemKind.Keyword: + return typescript.ScriptElementKind.keyword + case CompletionItemKind.Reference: + return typescript.ScriptElementKind.alias + case CompletionItemKind.File: + return typescript.ScriptElementKind.moduleElement + case CompletionItemKind.Snippet: + case CompletionItemKind.Text: + default: + return typescript.ScriptElementKind.unknown + } +} + +function getKindModifiers(item: CompletionItem): string { + return item.kind === CompletionItemKind.Color ? 'color' : '' +} + +function toDisplayParts(text: string | MarkupContent | undefined): ts.SymbolDisplayPart[] { + return text ? [{ kind: 'text', text: markupText(text) }] : [] +} diff --git a/src/features/css-diagnostic-code.ts b/src/features/css-diagnostic-code.ts new file mode 100644 index 0000000..7279af4 --- /dev/null +++ b/src/features/css-diagnostic-code.ts @@ -0,0 +1,8 @@ +export const CSS_DIAGNOSTIC_CODE = 9999 + +/** + * vscode-css-languageservice's stable parse-error id for "at-rule or selector expected" + * (ParseError.RuleOrSelectorExpected, cssErrors.ts). At the template end it reports the wrapper's + * own closing brace: the stray closing brace rule in docs/architecture.md, "Diagnostics". + */ +export const RULE_OR_SELECTOR_EXPECTED_DIAGNOSTIC_CODE = 'css-ruleorselectorexpected' diff --git a/src/features/diagnostics.ts b/src/features/diagnostics.ts new file mode 100644 index 0000000..69de72c --- /dev/null +++ b/src/features/diagnostics.ts @@ -0,0 +1,345 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' +import type { Diagnostic } from 'vscode-css-languageservice' +import { DiagnosticSeverity } from 'vscode-languageserver-types' + +import { pluginIdentity } from '../tsserver/plugin-identity.ts' +import { createCssCodeScanState, nonCodeEnd } from '../virtual-document/css-code-scanner.ts' +import { getTemplateCssText } from '../virtual-document/javascript-escapes.ts' +import { + StyledVirtualDocumentProvider, + type VirtualDocumentProvider, +} from '../virtual-document/styled-virtual-document-provider.ts' +import { + createTemplateLineMap, + fromVirtualDocSpan, + type TemplateLineMap, + type TemplateSpan, +} from '../virtual-document/template-line-map.ts' +import type { VirtualDocumentSessionProvider } from '../virtual-document/virtual-document-session-provider.ts' +import { + CSS_DIAGNOSTIC_CODE, + RULE_OR_SELECTOR_EXPECTED_DIAGNOSTIC_CODE, +} from './css-diagnostic-code.ts' +import type { ScssLanguageService } from './styles-language-services.ts' + +/** A CSS diagnostic as the editor shows it: the template span it lands on after mapping and re-anchoring. */ +export interface ShownDiagnostic extends TemplateSpan { + readonly diagnostic: Diagnostic +} + +/** + * Above this many entries, the least recently used raw-validation result is evicted (docs/ + * architecture.md, Caching): unbounded growth would hold every distinct template text a file ever + * had, across the whole editor session. Exported so tests can size a cache-filling fixture without + * hard-coding this number. + */ +export const MAX_VALIDATION_CACHE_ENTRIES = 2_000 + +/** + * Above this many estimated total bytes across every cached entry, the least recently used entry + * is evicted, independent of MAX_VALIDATION_CACHE_ENTRIES: a file with few, large templates (many + * diagnostics against a multi-kilobyte template, edited on every keystroke) can exhaust memory + * long before the count cap binds, since count alone says nothing about an entry's size. Exported + * so tests can size a cache-filling fixture without hard-coding this number. + */ +export const MAX_VALIDATION_CACHE_BYTES = 4 * 1024 * 1024 + +/** + * Per-diagnostic overhead beyond its message text (the range object, severity, code, and source + * fields, plus JS engine object bookkeeping), used only to estimate cache memory pressure, not to + * account for it exactly. Measured by cloning a real vscode-css-languageservice doValidation + * result (range, message, severity, code, source) into independent copies, forcing gc between + * growing batches, and dividing the settled retained-heap delta per copy by its diagnostic count, + * then subtracting each diagnostic's own message bytes (UTF-16 code units times 2) from that + * per-diagnostic total. + */ +export const VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES = 150 + +interface Validation { + readonly diagnostics: readonly Diagnostic[] + /** The line map that resolves the diagnostics, which a cache hit builds only when one needs mapping. */ + readonly getLineMap: () => TemplateLineMap +} + +export class DiagnosticsFeature { + private readonly validationCache = new RawValidationCache() + + public constructor( + private readonly typescript: typeof ts, + private readonly virtualDocumentProvider: VirtualDocumentProvider, + private readonly virtualDocumentSessionProvider: VirtualDocumentSessionProvider, + private readonly scssLanguageService: ScssLanguageService, + private readonly isValidationEnabled: () => boolean, + ) {} + + /** Lint settings change what doValidation returns for the same text, so a config update invalidates every entry. */ + public clearCache(): void { + this.validationCache.clear() + } + + public getSemanticDiagnostics(context: TemplateContext): ts.Diagnostic[] { + const file = context.node.getSourceFile() + return this.getShownDiagnostics(context).map(({ diagnostic, end, start }) => ({ + category: translateSeverity(this.typescript, diagnostic.severity), + code: typeof diagnostic.code === 'number' ? diagnostic.code : CSS_DIAGNOSTIC_CODE, + file, + length: end - start, + messageText: diagnostic.message, + source: pluginIdentity, + start, + })) + } + + /** + * The diagnostics the editor shows for a template (docs/architecture.md, "Diagnostics"): the one + * list both diagnostics and code fixes read, empty while validation is off. + */ + public getShownDiagnostics(context: TemplateContext): ShownDiagnostic[] { + if (!this.isValidationEnabled()) { + return [] + } + + const { diagnostics, getLineMap } = this.validate(context) + if (diagnostics.length === 0) { + return [] + } + + const lineMap = getLineMap() + const templateEnd = context.rawText.length + let strayBraceOffset: number | undefined + let hasSearchedStrayBrace = false + const shown: ShownDiagnostic[] = [] + for (const diagnostic of diagnostics) { + const span = fromVirtualDocSpan(this.virtualDocumentProvider, diagnostic.range, lineMap) + if (!span) { + continue + } + const reportsWrapperClose = + diagnostic.code === RULE_OR_SELECTOR_EXPECTED_DIAGNOSTIC_CODE && span.start === templateEnd + if (reportsWrapperClose && !hasSearchedStrayBrace) { + strayBraceOffset = findStrayClosingBraceOffset(getTemplateCssText(context)) + hasSearchedStrayBrace = true + } + const anchor = reportsWrapperClose ? strayBraceOffset : undefined + const start = anchor ?? span.start + const end = anchor === undefined ? span.end : anchor + 1 + shown.push({ diagnostic, end, start }) + } + return shown + } + + /** Raw validation results and the line map that resolves them, from RawValidationCache when it holds the template. */ + private validate(context: TemplateContext): Validation { + /** Only the built-in provider's documents are proven pure functions of the cache key (docs/architecture.md, "Caching"). */ + const styledProvider = + this.virtualDocumentProvider instanceof StyledVirtualDocumentProvider + ? this.virtualDocumentProvider + : undefined + const cacheKey = + styledProvider && + buildValidationCacheKey(styledProvider.getReadingKey(context), context.rawText) + const cachedDiagnostics = + cacheKey === undefined ? undefined : this.validationCache.peek(cacheKey) + if (cachedDiagnostics) { + /** A hit builds no document, and so never evicts the session's cached one. */ + return { + diagnostics: cachedDiagnostics, + getLineMap: () => + this.virtualDocumentSessionProvider.getReusableLineMap(context) ?? + createTemplateLineMap(context), + } + } + + const { document, lineMap, stylesheet } = + this.virtualDocumentSessionProvider.getParsedDocument(context) + let diagnostics = this.scssLanguageService.doValidation(document, stylesheet) + const valueReading = styledProvider?.createValueReadingDocument(context) + if (valueReading) { + const valueDiagnostics = this.scssLanguageService.doValidation( + valueReading, + this.scssLanguageService.parseStylesheet(valueReading), + ) + diagnostics = diagnostics.filter((diagnostic) => + valueDiagnostics.some((other) => isSameDiagnostic(diagnostic, other)), + ) + } + if (cacheKey !== undefined) { + this.validationCache.set(cacheKey, diagnostics) + } + return { diagnostics, getLineMap: () => lineMap } + } +} + +/** + * The stray closing brace (docs/architecture.md, "Diagnostics"): the first "}" in the template's CSS + * text with no matching opener, read with the boundary scanner, or undefined when every "}" is + * matched. + */ +function findStrayClosingBraceOffset(text: string): number | undefined { + let depth = 0 + const state = createCssCodeScanState() + for (let index = 0; index < text.length;) { + const end = nonCodeEnd(text, index, state) + if (end !== -1) { + index = end + continue + } + const character = text[index] + if (state.url) { + index++ + continue + } + if (character === '{') { + depth++ + } else if (character === '}') { + depth-- + if (depth < 0) { + return index + } + } + index++ + } + return undefined +} + +/** Same code, message, and range: one diagnostic under both readings of a single-identifier fragment. */ +function isSameDiagnostic(left: Diagnostic, right: Diagnostic): boolean { + return ( + left.code === right.code && + left.message === right.message && + left.range.start.line === right.range.start.line && + left.range.start.character === right.range.start.character && + left.range.end.line === right.range.end.line && + left.range.end.character === right.range.end.character + ) +} + +function translateSeverity( + typescript: typeof ts, + severity: DiagnosticSeverity | undefined, +): ts.DiagnosticCategory { + switch (severity) { + case DiagnosticSeverity.Information: + case DiagnosticSeverity.Hint: + return typescript.DiagnosticCategory.Message + case DiagnosticSeverity.Warning: + return typescript.DiagnosticCategory.Warning + case DiagnosticSeverity.Error: + default: + return typescript.DiagnosticCategory.Error + } +} + +interface ValidationCacheEntry { + readonly diagnostics: readonly Diagnostic[] + /** The flattened key this entry was stored under, so a recency touch re-inserts under it rather than under the lookup key. */ + readonly key: string + readonly sizeBytes: number +} + +/** + * Least-recently-used cache of raw vscode diagnostics (never translated ts.Diagnostics, which hold + * a `file: SourceFile` and must not outlive the request that produced them) keyed by a template's + * reading key plus its own raw, unsubstituted text (docs/architecture.md, Caching). A hit skips + * both the parse and the validation. Bounded by both MAX_VALIDATION_CACHE_ENTRIES and + * MAX_VALIDATION_CACHE_BYTES, whichever binds first; a single entry estimated larger than + * MAX_VALIDATION_CACHE_BYTES is never cached, since no eviction could ever make room for it. + */ +export class RawValidationCache { + private readonly entries = new Map() + private totalBytes = 0 + + /** The cached result for `rawKey`, now the most recently used, or undefined on a miss. */ + public peek(rawKey: string): readonly Diagnostic[] | undefined { + const cached = this.entries.get(rawKey) + if (!cached) { + return undefined + } + this.entries.delete(cached.key) + this.entries.set(cached.key, cached) + return cached.diagnostics + } + + /** + * Stores a freshly computed result for `rawKey`, evicting the least recently used entries first + * until the new entry fits under both caps. Rejects (without evicting anything) an entry whose + * own estimated size already exceeds MAX_VALIDATION_CACHE_BYTES. + */ + public set(rawKey: string, diagnostics: readonly Diagnostic[]): void { + const key = flattenCacheKey(rawKey) + const sizeBytes = estimateEntryBytes(key, diagnostics) + if (sizeBytes > MAX_VALIDATION_CACHE_BYTES) { + return + } + + const previous = this.entries.get(key) + if (previous) { + this.entries.delete(key) + this.totalBytes -= previous.sizeBytes + } + + for (const [oldestKey, oldestEntry] of this.entries) { + if (!this.exceedsCaps(sizeBytes)) { + break + } + this.entries.delete(oldestKey) + this.totalBytes -= oldestEntry.sizeBytes + } + + this.entries.set(key, { diagnostics, key, sizeBytes }) + this.totalBytes += sizeBytes + } + + public clear(): void { + this.entries.clear() + this.totalBytes = 0 + } + + private exceedsCaps(incomingSizeBytes: number): boolean { + return ( + this.entries.size >= MAX_VALIDATION_CACHE_ENTRIES || + this.totalBytes + incomingSizeBytes > MAX_VALIDATION_CACHE_BYTES + ) + } +} + +/** + * Estimates an entry's retained bytes as the key length plus, per diagnostic, its message length, + * both counted as UTF-16 code units times 2 bytes, plus the fixed per-diagnostic overhead + * (VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES). An estimate, not an exact accounting: close enough + * to keep RawValidationCache's total retained size within a low single-digit-megabyte budget + * regardless of how many diagnostics one template produces. + */ +function estimateEntryBytes(key: string, diagnostics: readonly Diagnostic[]): number { + let sizeBytes = key.length * 2 + for (const diagnostic of diagnostics) { + sizeBytes += diagnostic.message.length * 2 + VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES + } + return sizeBytes +} + +/** + * A copy of `key` that references none of the open file's text (docs/architecture.md, Caching, for + * the measurement behind this defensive step): `key` is built from `context.rawText`, a slice of the + * whole file, and V8 represents a slice of a long string as a view into its parent. Slicing the + * leading character back off a concatenation makes V8 flatten that concatenation into a new string + * first, so the result is at most a view into that fresh copy, one character longer than `key`, + * never into the file. Pure UTF-16 code-unit concatenation, unlike a UTF-8 round trip, needs no + * Node-only `Buffer` (this file is reachable from `./api`) and never rewrites an unpaired surrogate + * to U+FFFD, so two templates that differ only in one keep distinct keys. + */ +function flattenCacheKey(key: string): string { + return (' ' + key).slice(1) +} + +/** + * Length-prefixes the reading key so two different (reading key, rawText) pairs never concatenate + * to the same key: plain concatenation collides whenever one reading key is a prefix of another's + * own text plus its rawText ("a" with "bc" and "ab" with "c" both read "abc"). The decimal digits + * before the first ":" say exactly how many characters that follow belong to the reading key, and + * a decimal length never contains ":". + */ +export function buildValidationCacheKey(readingKey: string, rawText: string): string { + return `${readingKey.length}:${readingKey}${rawText}` +} diff --git a/src/features/folding.ts b/src/features/folding.ts new file mode 100644 index 0000000..57b1d69 --- /dev/null +++ b/src/features/folding.ts @@ -0,0 +1,50 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' +import type { FoldingRange } from 'vscode-css-languageservice' + +import type { VirtualDocumentProvider } from '../virtual-document/styled-virtual-document-provider.ts' +import { fromVirtualDocSpan, type TemplateLineMap } from '../virtual-document/template-line-map.ts' +import type { VirtualDocumentSessionProvider } from '../virtual-document/virtual-document-session-provider.ts' +import type { ScssLanguageService } from './styles-language-services.ts' + +export class FoldingFeature { + public constructor( + private readonly typescript: typeof ts, + private readonly virtualDocumentProvider: VirtualDocumentProvider, + private readonly virtualDocumentSessionProvider: VirtualDocumentSessionProvider, + private readonly scssLanguageService: ScssLanguageService, + ) {} + + public getOutliningSpans(context: TemplateContext): ts.OutliningSpan[] { + const { document, lineMap } = this.virtualDocumentSessionProvider.getDocument(context) + return this.scssLanguageService + .getFoldingRanges(document) + .map((range) => this.translateRange(range, lineMap)) + .filter((range) => range !== undefined) + } + + private translateRange( + range: FoldingRange, + lineMap: TemplateLineMap, + ): ts.OutliningSpan | undefined { + const span = fromVirtualDocSpan( + this.virtualDocumentProvider, + { + end: { line: range.endLine, character: range.endCharacter ?? 0 }, + start: { line: range.startLine, character: range.startCharacter ?? 0 }, + }, + lineMap, + ) + if (!span) { + return undefined + } + const textSpan = { length: span.end - span.start, start: span.start } + return { + autoCollapse: false, + bannerText: '', + hintSpan: textSpan, + kind: this.typescript.OutliningSpanKind.Code, + textSpan, + } + } +} diff --git a/src/features/hover.ts b/src/features/hover.ts new file mode 100644 index 0000000..de3f064 --- /dev/null +++ b/src/features/hover.ts @@ -0,0 +1,67 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' +import type { Hover } from 'vscode-css-languageservice' + +import type { VirtualDocumentProvider } from '../virtual-document/styled-virtual-document-provider.ts' +import { fromVirtualDocSpan, type TemplateLineMap } from '../virtual-document/template-line-map.ts' +import type { VirtualDocumentSessionProvider } from '../virtual-document/virtual-document-session-provider.ts' +import { markupText } from './markup-text.ts' +import type { ScssLanguageService } from './styles-language-services.ts' + +export class HoverFeature { + public constructor( + private readonly typescript: typeof ts, + private readonly virtualDocumentProvider: VirtualDocumentProvider, + private readonly virtualDocumentSessionProvider: VirtualDocumentSessionProvider, + private readonly scssLanguageService: ScssLanguageService, + ) {} + + public getQuickInfoAtPosition( + context: TemplateContext, + position: ts.LineAndCharacter, + ): ts.QuickInfo | undefined { + if (context.rawText.length === 0) { + return undefined + } + + const { document, lineMap, stylesheet } = + this.virtualDocumentSessionProvider.getParsedDocument(context) + const virtualPosition = this.virtualDocumentProvider.toVirtualDocPosition(position) + const hover = this.scssLanguageService.doHover(document, virtualPosition, stylesheet) + return hover ? this.translateHover(hover, virtualPosition, lineMap) : undefined + } + + private translateHover( + hover: Hover, + virtualPosition: ts.LineAndCharacter, + lineMap: TemplateLineMap, + ): ts.QuickInfo | undefined { + const span = fromVirtualDocSpan( + this.virtualDocumentProvider, + hover.range ?? { end: virtualPosition, start: virtualPosition }, + lineMap, + ) + if (!span) { + return undefined + } + + const length = hover.range + ? span.end - span.start + : Math.min(1, lineMap.templateEnd.offset - span.start) + return { + displayParts: [], + documentation: toDisplayParts(hover.contents), + kind: this.typescript.ScriptElementKind.unknown, + kindModifiers: '', + tags: [], + textSpan: { length, start: span.start }, + } + } +} + +function toDisplayParts(contents: Hover['contents']): ts.SymbolDisplayPart[] { + if (Array.isArray(contents)) { + return contents.flatMap(toDisplayParts) + } + return [{ kind: 'unknown', text: markupText(contents) }] +} diff --git a/src/features/markup-text.ts b/src/features/markup-text.ts new file mode 100644 index 0000000..1fda3b9 --- /dev/null +++ b/src/features/markup-text.ts @@ -0,0 +1,6 @@ +import type { MarkedString, MarkupContent } from 'vscode-css-languageservice' + +/** The text of CSS service documentation, which arrives as a plain string or as markup with a `value`. */ +export function markupText(content: MarkedString | MarkupContent): string { + return typeof content === 'string' ? content : content.value +} diff --git a/src/features/styles-language-services.ts b/src/features/styles-language-services.ts new file mode 100644 index 0000000..412ee3f --- /dev/null +++ b/src/features/styles-language-services.ts @@ -0,0 +1,52 @@ +import { doComplete as emmetDoComplete } from '@vscode/emmet-helper' +import { getCSSLanguageService, getSCSSLanguageService } from 'vscode-css-languageservice' +import type { CompletionList, LanguageService, Position } from 'vscode-css-languageservice' +import type { TextDocument } from 'vscode-languageserver-textdocument' + +import type { StyledPluginEmmetConfiguration } from '../configuration/plugin-configuration.ts' + +export type CssLanguageService = Pick + +export type ScssLanguageService = Pick< + LanguageService, + | 'configure' + | 'parseStylesheet' + | 'doComplete' + | 'doHover' + | 'doValidation' + | 'doCodeActions' + | 'getFoldingRanges' +> + +export interface StylesLanguageServiceFactory { + createCssLanguageService(): CssLanguageService + createScssLanguageService(): ScssLanguageService +} + +export interface EmmetCompletionProvider { + doComplete( + document: TextDocument, + position: Position, + configuration: StyledPluginEmmetConfiguration, + ): CompletionList | undefined +} + +export class DefaultStylesLanguageServiceFactory implements StylesLanguageServiceFactory { + public createCssLanguageService(): CssLanguageService { + return getCSSLanguageService() + } + + public createScssLanguageService(): ScssLanguageService { + return getSCSSLanguageService() + } +} + +export class DefaultEmmetCompletionProvider implements EmmetCompletionProvider { + public doComplete( + document: TextDocument, + position: Position, + configuration: StyledPluginEmmetConfiguration, + ): CompletionList | undefined { + return emmetDoComplete(document, position, 'css', configuration) + } +} diff --git a/src/index.ts b/src/index.ts index 1b47c7d..d008395 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,20 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as ts from 'typescript/lib/tsserverlibrary'; -import { StyledPlugin } from './_plugin'; +import type * as ts from 'typescript/lib/tsserverlibrary.js' -export = (mod: { typescript: typeof ts }) => new StyledPlugin(mod.typescript); +import { TsServerStyledPlugin } from './tsserver/tsserver-plugin.ts' + +/** + * 1.0.1's published return type, with both members required and `any` config, rather than + * `ts.server.PluginModule`, where `onConfigurationChanged` is optional: a 1.0.1 consumer's + * unguarded `plugin(...).onConfigurationChanged(...)` call must keep compiling. + */ +interface StyledPlugin { + create(info: ts.server.PluginCreateInfo): ts.LanguageService + onConfigurationChanged(config: any): void +} + +const createPlugin = (mod: { typescript: typeof ts }): StyledPlugin => + new TsServerStyledPlugin(mod.typescript) + +export default createPlugin diff --git a/src/template-language-service.ts b/src/template-language-service.ts new file mode 100644 index 0000000..70d5175 --- /dev/null +++ b/src/template-language-service.ts @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Original code forked from https://github.com/Quramy/ts-graphql-plugin + +import type { + TemplateContext, + TemplateLanguageService, +} from 'typescript-template-language-service-decorator' +import type { Logger } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' + +import { PluginConfigurationManager } from './configuration/plugin-configuration.ts' +import { CodeActionsFeature } from './features/code-actions.ts' +import { + CompletionsFeature, + createEmptyCompletionInfo, + createUnknownCompletionEntryDetails, +} from './features/completions.ts' +import { CSS_DIAGNOSTIC_CODE } from './features/css-diagnostic-code.ts' +import { DiagnosticsFeature } from './features/diagnostics.ts' +import { FoldingFeature } from './features/folding.ts' +import { HoverFeature } from './features/hover.ts' +import { + DefaultEmmetCompletionProvider, + DefaultStylesLanguageServiceFactory, +} from './features/styles-language-services.ts' +import type { + CssLanguageService, + EmmetCompletionProvider, + StylesLanguageServiceFactory, + ScssLanguageService, +} from './features/styles-language-services.ts' +import type { VirtualDocumentProvider } from './virtual-document/styled-virtual-document-provider.ts' +import { + CachedVirtualDocumentSessionProvider, + type VirtualDocumentSessionProvider, +} from './virtual-document/virtual-document-session-provider.ts' + +export class StyledTemplateLanguageService implements TemplateLanguageService { + private codeActionsFeature?: CodeActionsFeature + private completionsFeature?: CompletionsFeature + private readonly configurationManager: PluginConfigurationManager + private cssLanguageServiceInstance?: CssLanguageService + private diagnosticsFeature?: DiagnosticsFeature + private readonly emmetCompletionProvider: EmmetCompletionProvider + private foldingFeature?: FoldingFeature + private hoverFeature?: HoverFeature + private readonly languageServiceFactory: StylesLanguageServiceFactory + private readonly logger: Logger | undefined + private scssLanguageServiceInstance?: ScssLanguageService + private readonly typescript: typeof ts + private readonly virtualDocumentProvider: VirtualDocumentProvider + private virtualDocumentSessionProviderInstance?: VirtualDocumentSessionProvider + + public constructor( + typescript: typeof ts, + configurationManager: PluginConfigurationManager, + virtualDocumentProvider: VirtualDocumentProvider, + logger: Logger, + emmetCompletionProvider?: EmmetCompletionProvider, + ) + public constructor( + typescript: typeof ts, + configurationManager: PluginConfigurationManager, + virtualDocumentProvider: VirtualDocumentProvider, + languageServiceFactory?: StylesLanguageServiceFactory, + emmetCompletionProvider?: EmmetCompletionProvider, + ) + public constructor( + typescript: typeof ts, + configurationManager: PluginConfigurationManager, + virtualDocumentProvider: VirtualDocumentProvider, + loggerOrLanguageServiceFactory?: Logger | StylesLanguageServiceFactory, + emmetCompletionProvider: EmmetCompletionProvider = new DefaultEmmetCompletionProvider(), + ) { + this.typescript = typescript + this.configurationManager = configurationManager + this.virtualDocumentProvider = virtualDocumentProvider + this.languageServiceFactory = isStylesLanguageServiceFactory(loggerOrLanguageServiceFactory) + ? loggerOrLanguageServiceFactory + : new DefaultStylesLanguageServiceFactory() + this.logger = isStylesLanguageServiceFactory(loggerOrLanguageServiceFactory) + ? undefined + : loggerOrLanguageServiceFactory + this.emmetCompletionProvider = emmetCompletionProvider + configurationManager.onUpdatedConfig(() => { + this.completionsFeature?.clearCache() + this.diagnosticsFeature?.clearCache() + this.cssLanguageServiceInstance?.configure(this.configurationManager.config) + this.scssLanguageServiceInstance?.configure(this.configurationManager.config) + }) + } + + public getCompletionsAtPosition( + context: TemplateContext, + position: ts.LineAndCharacter, + ): ts.WithMetadata { + return this.recover('getCompletionsAtPosition', createEmptyCompletionInfo, () => + this.completions.getCompletionsAtPosition(context, position), + ) + } + + public getCompletionEntryDetails( + context: TemplateContext, + position: ts.LineAndCharacter, + name: string, + ): ts.CompletionEntryDetails { + return this.recover( + 'getCompletionEntryDetails', + () => createUnknownCompletionEntryDetails(this.typescript, name), + () => this.completions.getCompletionEntryDetails(context, position, name), + ) + } + + public getQuickInfoAtPosition( + context: TemplateContext, + position: ts.LineAndCharacter, + ): ts.QuickInfo | undefined { + return this.recover( + 'getQuickInfoAtPosition', + () => undefined, + () => this.hover.getQuickInfoAtPosition(context, position), + ) + } + + public getSemanticDiagnostics(context: TemplateContext): ts.Diagnostic[] { + return this.recover( + 'getSemanticDiagnostics', + () => [], + () => this.diagnostics.getSemanticDiagnostics(context), + ) + } + + public getSupportedCodeFixes(): number[] { + return [CSS_DIAGNOSTIC_CODE] + } + + public getCodeFixesAtPosition( + context: TemplateContext, + start: number, + end: number, + errorCodes?: readonly number[], + ): ts.CodeAction[] { + return this.recover( + 'getCodeFixesAtPosition', + () => [], + () => this.codeActions.getCodeFixesAtPosition(context, start, end, errorCodes), + ) + } + + public getOutliningSpans(context: TemplateContext): ts.OutliningSpan[] { + return this.recover( + 'getOutliningSpans', + () => [], + () => this.folding.getOutliningSpans(context), + ) + } + + /** Exception recovery for every entry point (docs/architecture.md, "Exception recovery"). */ + private recover(method: string, fallback: () => T, run: () => T): T { + try { + return run() + } catch (error) { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error) + this.logger?.log(`${method} threw and was recovered: ${message}`) + return fallback() + } + } + + private get codeActions(): CodeActionsFeature { + this.codeActionsFeature ??= new CodeActionsFeature( + this.virtualDocumentProvider, + this.virtualDocumentSessionProvider, + this.scssLanguageService, + this.diagnostics, + ) + return this.codeActionsFeature + } + + private get completions(): CompletionsFeature { + this.completionsFeature ??= new CompletionsFeature( + this.typescript, + this.virtualDocumentProvider, + this.virtualDocumentSessionProvider, + this.cssLanguageService, + this.scssLanguageService, + this.emmetCompletionProvider, + () => this.configurationManager.config, + ) + return this.completionsFeature + } + + private get cssLanguageService(): CssLanguageService { + this.cssLanguageServiceInstance ??= this.configured( + this.languageServiceFactory.createCssLanguageService(), + ) + return this.cssLanguageServiceInstance + } + + private get diagnostics(): DiagnosticsFeature { + this.diagnosticsFeature ??= new DiagnosticsFeature( + this.typescript, + this.virtualDocumentProvider, + this.virtualDocumentSessionProvider, + this.scssLanguageService, + () => this.configurationManager.config.validate, + ) + return this.diagnosticsFeature + } + + private get folding(): FoldingFeature { + this.foldingFeature ??= new FoldingFeature( + this.typescript, + this.virtualDocumentProvider, + this.virtualDocumentSessionProvider, + this.scssLanguageService, + ) + return this.foldingFeature + } + + private get hover(): HoverFeature { + this.hoverFeature ??= new HoverFeature( + this.typescript, + this.virtualDocumentProvider, + this.virtualDocumentSessionProvider, + this.scssLanguageService, + ) + return this.hoverFeature + } + + private get scssLanguageService(): ScssLanguageService { + this.scssLanguageServiceInstance ??= this.configured( + this.languageServiceFactory.createScssLanguageService(), + ) + return this.scssLanguageServiceInstance + } + + private get virtualDocumentSessionProvider(): VirtualDocumentSessionProvider { + this.virtualDocumentSessionProviderInstance ??= new CachedVirtualDocumentSessionProvider( + this.virtualDocumentProvider, + this.scssLanguageService, + ) + return this.virtualDocumentSessionProviderInstance + } + + private configured( + service: Service, + ): Service { + service.configure(this.configurationManager.config) + return service + } +} + +function isStylesLanguageServiceFactory( + value: Logger | StylesLanguageServiceFactory | undefined, +): value is StylesLanguageServiceFactory { + return value !== undefined && 'createCssLanguageService' in value +} diff --git a/src/template-settings.ts b/src/template-settings.ts new file mode 100644 index 0000000..091b94f --- /dev/null +++ b/src/template-settings.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import type { TemplateSettings } from 'typescript-template-language-service-decorator' + +import type { PluginConfigurationManager } from './configuration/plugin-configuration.ts' +import { getTemplateSubstitutions } from './template/template-substitutions.ts' + +export function getTemplateSettings( + configurationManager: PluginConfigurationManager, +): TemplateSettings { + return { + get tags() { + return configurationManager.config.tags + }, + enableForStringWithSubstitutions: true, + getSubstitutions: getTemplateSubstitutions, + } +} diff --git a/src/template/template-substitutions.ts b/src/template/template-substitutions.ts new file mode 100644 index 0000000..49d4976 --- /dev/null +++ b/src/template/template-substitutions.ts @@ -0,0 +1,1012 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { + createCodeLookback, + findNonCodeRuns, + INSIDE_NON_CODE, + isLineTerminator, + isNameCharacter, + isStatementBoundary, + isWhitespace, + LINE_SEPARATOR, + NO_SIGNIFICANT_CHARACTER, + type NonCodeRuns, + PARAGRAPH_SEPARATOR, + type PreviousSignificant, +} from '../virtual-document/css-code-scanner.ts' +import { replaceJavaScriptEscapes } from '../virtual-document/javascript-escapes.ts' + +interface SubstitutionSpan { + readonly end: number + readonly start: number +} + +export function getTemplateSubstitutions( + templateText: string, + substitutionSpans: ReadonlyArray, +): string { + const spans = normalizeSpans(templateText.length, substitutionSpans) + if (spans.length === 0) { + return templateText + } + + const substitutedParts: string[] = [] + /** + * Every decision below reads the masked text with JavaScript escapes replaced, the characters + * styled-components receives (docs/architecture.md, Virtual document); the output keeps the + * raw text between placeholders, which the virtual document replaces the same way. + */ + const syntaxText = replaceJavaScriptEscapes(maskSubstitutions(templateText, spans)) + const nonCodeRuns = findNonCodeRuns(syntaxText) + const boundaryScanner = createSyntaxBoundaryScanner(syntaxText, nonCodeRuns) + const codeLookback = createCodeLookback(syntaxText, 0) + const findLineConditionKeyword = createConditionKeywordFinder(syntaxText) + const findStatementConditionKeyword = createConditionKeywordFinder(syntaxText) + const scanState = createScanState(syntaxText, nonCodeRuns) + let lastSpanStart = 0 + let lastOffset = 0 + let previousAtStatementBoundary = false + + for (const span of spans) { + advanceScanState(scanState, syntaxText, span.start) + const onlyWhitespaceSinceLineStart = !scanState.sawNonWhitespace + const previousSignificantOffset = codeLookback.previousSignificant(span.start) + /** + * Only whitespace and comments separate this placeholder from the previous one when the + * nearest significant character before it lies inside the previous placeholder's own span, + * whose masked filler is code. An empty span, or one inside a comment or string, holds no such + * character, so the answer then comes from the real character before it. + */ + const followsPreviousPlaceholder = + previousSignificantOffset >= lastSpanStart && previousSignificantOffset < lastOffset + const followsBoundaryCharacter = + previousSignificantOffset === NO_SIGNIFICANT_CHARACTER || + (previousSignificantOffset !== INSIDE_NON_CODE && + isStatementBoundary(syntaxText[previousSignificantOffset])) + const followsStatementBoundary: boolean = followsPreviousPlaceholder + ? previousAtStatementBoundary + : followsBoundaryCharacter + const statementStart = scanState.boundaryOffset + 1 + const syntaxTextSinceBoundary = syntaxText.slice(statementStart, span.start) + const conditionKeyword = + findLineConditionKeyword(statementStart) ?? + findStatementConditionKeyword(scanState.statementBoundaryOffset + 1) + advanceScanState(scanState, syntaxText, span.end) + + const nextSignificantOffset = boundaryScanner.nextNonWhitespace(span.end) + const nextSignificant = syntaxText[nextSignificantOffset] + const nameEnd = boundaryScanner.identifierEnd(span.end) + const characterBefore = syntaxText[span.start - 1] + const isJoinedToName = nameEnd > span.end || isNameCharacter(characterBefore) + const { isPropertyNamePosition, isSelectorPosition } = classifyPlaceholderPosition( + boundaryScanner, + nameEnd > span.end + ? boundaryScanner.nextNonWhitespaceAfterName(nameEnd) + : nextSignificantOffset, + ) + const atStatementBoundary: boolean = + followsStatementBoundary && !STATEMENT_SLOT_EXCLUDED_NEXT.has(nextSignificant ?? '') + const gapStart = skipWhitespaceBackward(syntaxText, span.start, lastOffset) + + substitutedParts.push(templateText.slice(lastOffset, span.start)) + substitutedParts.push( + getSubstitution({ + atRuleCondition: conditionKeyword + ? getAtRuleConditionShape(syntaxText, conditionKeyword, { + nextSignificant: nextSignificantOffset, + previousSignificant: previousSignificantOffset, + start: span.start, + }) + : undefined, + atStatementBoundary, + characterAfter: syntaxText[span.end], + followsHash: gapStart > lastOffset && syntaxText[gapStart - 1] === '#', + followsStatementBoundary, + isJoinedToName, + isJoinedToSelectorName: isJoinedToName || SELECTOR_NAME_PREFIXES.has(characterBefore ?? ''), + isPropertyNamePosition, + isSelectorPosition, + nextSignificant, + onlyWhitespaceSinceLineStart, + placeholderText: templateText.slice(span.start, span.end), + syntaxTextSinceBoundary, + }), + ) + previousAtStatementBoundary = atStatementBoundary + lastSpanStart = span.start + lastOffset = span.end + } + substitutedParts.push(templateText.slice(lastOffset)) + return substitutedParts.join('') +} + +/** + * Answers, for the syntax-masked text after a placeholder, the questions the statement rule and + * the property-name and selector branches of getSubstitution ask: where the nearest non-whitespace + * character after it is, where the name it is joined to ends, and where the next "{", "&", ";", + * "}", or line terminator at code after it is (the code stops). Each query scans lazily rather than + * over the whole text up front, and total work stays linear in the text length for any number of + * placeholders because each query kind remembers its last range: a later query starting inside that + * range reuses the answer instead of rescanning it, which covers many placeholders followed by ":" + * on one long line with no "{", many adjacent placeholders forming one name, and many placeholders + * sharing the same run of whitespace. The nearest significant character before a placeholder comes + * from a CodeLookback instead. The three non-whitespace queries skip whole comment runs (never + * strings or unquoted url() arguments, which are significant content), so a comment between a + * placeholder and the character that decides its role (`${Child} /* note *\/ { color: red; }`, + * `${p} /* note *\/: red;`) never hides that character. + */ +interface SyntaxBoundaryScanner { + /** The end of the run of name characters starting at `from` (masked placeholders count). */ + identifierEnd(from: number): number + nextNonWhitespace(from: number): number + /** nextNonWhitespace for the end of a name, with its own remembered range. */ + nextNonWhitespaceAfterName(from: number): number + /** nextNonWhitespace for a line terminator nextSpecial found, with its own remembered range. */ + nextNonWhitespaceAfterLine(from: number): number + nextSpecial(from: number): CodeStop + /** + * nextSpecial for the lines after a selector line ending in ",", which keeps going past each + * further line ending in ",". + */ + nextSpecialInList(from: number): CodeStop + readonly text: string +} + +function createSyntaxBoundaryScanner(text: string, runs: NonCodeRuns): SyntaxBoundaryScanner { + return { + identifierEnd: createRunScanner(text, (character) => !isNameCharacter(character)), + nextNonWhitespace: createNonWhitespaceScanner(text, runs), + nextNonWhitespaceAfterLine: createNonWhitespaceScanner(text, runs), + nextNonWhitespaceAfterName: createNonWhitespaceScanner(text, runs), + nextSpecial: createCodeStopScanner(text, runs), + nextSpecialInList: createListContinuationScanner(createCodeStopScanner(text, runs)), + text, + } +} + +/** + * A character that ends the search for a selector's "{" or "&" after a placeholder's ":": that + * "{" or "&" itself, a line terminator, or a ";" or "}", which ends the statement the ":" is in, so + * a "{" or "&" after it belongs to a later statement (`padding-${s}: 0; &:hover {`). + */ +function isSelectorScanStop(character: string): boolean { + return ( + character === '{' || + character === '&' || + isStatementBoundary(character) || + isLineTerminator(character) + ) +} + +interface CodeStop { + /** + * True when the stop is a line terminator ending a line whose last code character is ",", so a + * selector list continues past it. Both halves of a CRLF carry the same answer. + */ + readonly endsListLine: boolean + /** The stop's offset, or the text length when none follows. */ + readonly offset: number +} + +const NO_CODE_STOP: CodeStop = { endsListLine: false, offset: -1 } + +/** + * nextSpecial: the first selector scan stop at code at or after `from`, read character by + * character, stepping over each non-code run a stop falls in (a text of plain code has none). + * Every position in [from, answer) is no code stop, so a later query starting inside that range + * has the same answer: the query remembers it. Any other query starts past the last answer, since + * each placeholder lies past the previous one, so the index of the next run only moves forward. + */ +function createCodeStopScanner(text: string, runs: NonCodeRuns): (from: number) => CodeStop { + const { ends, starts } = runs + let run = 0 + let queryFrom = -1 + let found = NO_CODE_STOP + return (from) => { + if (from >= queryFrom && from <= found.offset) { + return found + } + let offset = from + for (;;) { + while (offset < text.length && !isSelectorScanStop(text[offset])) { + offset++ + } + while (run < ends.length && ends[run] <= offset) { + run++ + } + if (run === ends.length || starts[run] > offset) { + break + } + offset = ends[run] + } + queryFrom = from + found = { + endsListLine: + isLineTerminator(text[offset]) && lineEndsWithComma(text, offset, runs, run - 1), + offset, + } + return found + } +} + +/** + * True when the line ending at the line terminator at `lineBreak`, at code, has "," as its last + * code character. Reads back over that one line's trailing whitespace and comments only (the "\r" + * of a CRLF included), so a blank line ends a list and each line is read once. A string, escape, + * or url() argument there is a significant character other than ",". `lastRun` is the index of the + * last run that starts before `lineBreak`, or -1. + */ +function lineEndsWithComma( + text: string, + lineBreak: number, + runs: NonCodeRuns, + lastRun: number, +): boolean { + const { ends, isComment, starts } = runs + let index = lineBreak - 1 + if (text[lineBreak] === '\n' && text[index] === '\r') { + index-- + } + let run = lastRun + while (index >= 0) { + if (run >= 0 && ends[run] > index) { + if (!isComment[run]) { + return false + } + index = starts[run] - 1 + run-- + continue + } + const character = text[index] + if (isLineTerminator(character) || !isWhitespace(character)) { + return character === ',' + } + index-- + } + return false +} + +/** + * Returns nextSpecialInList over its own stop scanner. Every line terminator it steps past ends a + * line whose last code character is ",", and every other position it steps past is no stop, so a + * later query starting inside the range of the last answer has that same answer: the query + * remembers it, which keeps a long selector list with a placeholder on every line linear. + */ +function createListContinuationScanner( + nextStop: (from: number) => CodeStop, +): (from: number) => CodeStop { + let queryFrom = -1 + let found = NO_CODE_STOP + return (from) => { + if (from >= queryFrom && from <= found.offset) { + return found + } + let stop = nextStop(from) + while (stop.endsListLine) { + stop = nextStop(stop.offset + 1) + } + queryFrom = from + found = stop + return stop + } +} + +/** + * Returns a query for the first offset at or after `from` whose character satisfies `isStop` (the + * text length when none does). Every character in [from, answer) fails `isStop`, so the answer for + * any later query starting inside that range is the same: the query remembers its last range and + * answers such a query without scanning. + */ +function createRunScanner( + text: string, + isStop: (character: string) => boolean, +): (from: number) => number { + let queryFrom = -1 + let found = -1 + return (from) => { + if (from >= queryFrom && from <= found) { + return found + } + let index = from + while (index < text.length && !isStop(text[index])) { + index++ + } + queryFrom = from + found = index + return index + } +} + +/** + * createRunScanner for the nearest non-whitespace character, with every whole comment run in its + * way stepped over (never a string or unquoted url() argument, whose first character is already + * non-whitespace and stops the scan there, significant content). Built on the same plain scanner a + * template with no non-code run returns outright, so a template with no comment takes the same path + * as before and pays nothing new. Runs of a comment-free stretch cost the plain scanner's one search; + * a run only makes the query loop once more per comment it steps over, and the shared run index only + * moves forward, so total work across every placeholder's queries stays linear in the text length. + */ +function createNonWhitespaceScanner(text: string, runs: NonCodeRuns): (from: number) => number { + const plainScan = createRunScanner(text, (character) => !isWhitespace(character)) + if (runs.starts.length === 0) { + return plainScan + } + const { ends, isComment, starts } = runs + let run = 0 + let queryFrom = -1 + let found = -1 + return (from) => { + if (from >= queryFrom && from <= found) { + return found + } + let offset = from + for (;;) { + offset = plainScan(offset) + while (run < ends.length && ends[run] <= offset) { + run++ + } + if (run === ends.length || starts[run] > offset) { + break + } + if (!isComment[run]) { + break + } + offset = ends[run] + } + queryFrom = from + found = offset + return offset + } +} + +/** + * Property-name position is a ":" at `colonPosition` without the selector shape; selector position + * is a ":" with it: starting at least one character after that ":", the nearest stop at code + * (createCodeStopScanner) is a "{" or "&", not a ";" or "}" that ends the statement first + * (`padding-${s}: 0; &:hover {`), with the line-break cases below. `colonPosition` is the first + * non-whitespace offset after the placeholder, or after the rest of the name it is joined to + * (`padding-${side}-top:`), computed once per placeholder by the caller. + */ +function classifyPlaceholderPosition( + scanner: SyntaxBoundaryScanner, + colonPosition: number, +): { isPropertyNamePosition: boolean; isSelectorPosition: boolean } { + const { text } = scanner + if (text[colonPosition] !== ':') { + return { isPropertyNamePosition: false, isSelectorPosition: false } + } + + /** A line terminator right after the ":" makes a property name. */ + const afterColon = colonPosition + 1 + if (isLineTerminator(text[afterColon])) { + return { isPropertyNamePosition: true, isSelectorPosition: false } + } + + /** + * A rule body may also open on a later line, with only whitespace before its "{" there + * (`${B}:not(:last-child)` then `{` on the next line); a declaration's next line never starts + * with "{". A line ending in "," continues a selector list on the next line (`${C}:hover,` then + * `${C}:focus {`), where a ";" or "}" before any "{" or "&" marks a declaration value instead + * (`${prop}: a,` then `b;`). + */ + const firstStop = scanner.nextSpecial(afterColon + 1) + const stop = firstStop.endsListLine ? scanner.nextSpecialInList(firstStop.offset + 1) : firstStop + const special = text[stop.offset] + const isSelectorPosition = + special === '{' || + special === '&' || + (isLineTerminator(special) && text[scanner.nextNonWhitespaceAfterLine(stop.offset)] === '{') + return { isPropertyNamePosition: !isSelectorPosition, isSelectorPosition } +} + +/** + * State threaded left to right across every placeholder in one pass, so a placeholder's decisions + * never redo work proportional to its line length (docs/architecture.md, substitution invariants): + * `sawNonWhitespace` is whether a non-whitespace character has appeared on the current line; + * `boundaryOffset` is the offset of the most recent ";", "{", or "}" at code on the current line, + * or of the line terminator that starts the line (-1 on the first line); `statementBoundaryOffset` + * is the offset of the most recent ";", "{", or "}" at code on any line, or -1. A line terminator + * or non-whitespace character inside a comment or string still counts for the line facts. + */ +interface ScanState { + boundaryOffset: number + cursor: number + /** The first non-whitespace character at or after an offset. */ + readonly nextNonWhitespace: (from: number) => number + /** The first line terminator, or ";", "{", or "}" at code, at or after an offset. */ + readonly nextStop: (from: number) => number + sawNonWhitespace: boolean + statementBoundaryOffset: number +} + +const LINE_TERMINATOR_CLASS = `\\n\\r${LINE_SEPARATOR}${PARAGRAPH_SEPARATOR}` +const LINE_OR_STATEMENT_BOUNDARY_PATTERN = new RegExp(`[${LINE_TERMINATOR_CLASS};{}]`, 'g') +const LINE_TERMINATOR_PATTERN = new RegExp(`[${LINE_TERMINATOR_CLASS}]`, 'g') +const NON_WHITESPACE_PATTERN = /\S/g + +function createScanState(text: string, runs: NonCodeRuns): ScanState { + const nextLineOrBoundary = createCharacterSearch(text, LINE_OR_STATEMENT_BOUNDARY_PATTERN) + return { + boundaryOffset: -1, + cursor: 0, + nextNonWhitespace: createCharacterSearch(text, NON_WHITESPACE_PATTERN), + nextStop: + runs.starts.length === 0 + ? nextLineOrBoundary + : createLineOrCodeBoundarySearch(text, runs, nextLineOrBoundary), + sawNonWhitespace: false, + statementBoundaryOffset: -1, + } +} + +/** + * Returns a query for the first offset at or after `from` where `pattern`, a global pattern that + * matches one UTF-16 code unit, matches (the text length when none does). The query remembers its + * last range, as createRunScanner does, so queries at increasing offsets read the text once. + */ +function createCharacterSearch(text: string, pattern: RegExp): (from: number) => number { + const search = new RegExp(pattern) + let queryFrom = -1 + let found = -1 + return (from) => { + if (from >= queryFrom && from <= found) { + return found + } + search.lastIndex = from + queryFrom = from + found = search.test(text) ? search.lastIndex - 1 : text.length + return found + } +} + +/** + * Returns a query for the first line terminator, anywhere, or ";", "{", or "}" at code, at or after + * `from`, for a text with non-code runs. A boundary inside a run gives way to the next line + * terminator inside that run, if any, and otherwise to the search past the run, so each run is + * stepped over once rather than boundary by boundary. It remembers its last range, as + * createRunScanner does; any other query starts past the last answer, so the index of the next run + * only moves forward. + */ +function createLineOrCodeBoundarySearch( + text: string, + runs: NonCodeRuns, + nextLineOrBoundary: (from: number) => number, +): (from: number) => number { + const { ends, starts } = runs + const nextLineTerminator = createCharacterSearch(text, LINE_TERMINATOR_PATTERN) + let run = 0 + let queryFrom = -1 + let found = -1 + return (from) => { + if (from >= queryFrom && from <= found) { + return found + } + let offset = nextLineOrBoundary(from) + while (offset < text.length && !isLineTerminator(text[offset])) { + while (run < ends.length && ends[run] <= offset) { + run++ + } + if (run === ends.length || starts[run] > offset) { + break + } + const lineTerminator = nextLineTerminator(offset + 1) + if (lineTerminator < ends[run]) { + offset = lineTerminator + break + } + offset = nextLineOrBoundary(ends[run]) + } + queryFrom = from + found = offset + return offset + } +} + +/** + * A next non-whitespace character that keeps a placeholder after a statement boundary out of block + * position: it opens a rule body, follows a property name, continues a selector list, continues + * a compound or complex selector the placeholder is part of (for example `${Child}.active {`), or + * makes the placeholder a percentage (a keyframe selector such as `${step}% {`). + */ +const STATEMENT_SLOT_EXCLUDED_NEXT: ReadonlySet = new Set([ + '#', + '%', + '&', + '*', + '+', + ',', + '.', + ':', + '>', + '[', + '{', + '~', +]) + +/** + * A next non-whitespace character that keeps a placeholder alone on its line out of block + * position: it opens a rule body, follows a property name, continues a list of values, or makes + * the placeholder a percentage. + */ +const LINE_SLOT_EXCLUDED_NEXT: ReadonlySet = new Set(['%', ',', ':', '{']) + +/** + * Advances `state.cursor` from where the previous call left it up to `to`, jumping from one stop + * (a line terminator, or a ";", "{", or "}" at code) to the next and reading whether a stretch + * between two holds non-whitespace with one search, not one character at a time. A placeholder's + * own masked span is filler (never ";", "{", or "}"), so only the template's own characters count + * as boundaries. A CRLF is one line terminator, at its "\n". + */ +function advanceScanState(state: ScanState, syntaxText: string, to: number) { + let from = state.cursor + while (from < to) { + const stop = Math.min(state.nextStop(from), to) + if (!state.sawNonWhitespace && stop > from) { + state.sawNonWhitespace = state.nextNonWhitespace(from) < stop + } + if (stop === to) { + from = to + break + } + let index = stop + if (syntaxText[index] === '\r' && syntaxText[index + 1] === '\n') { + index++ + } + if (isLineTerminator(syntaxText[index])) { + state.sawNonWhitespace = false + } else { + state.statementBoundaryOffset = index + state.sawNonWhitespace = true + } + state.boundaryOffset = index + from = index + 1 + } + state.cursor = from +} + +function getSubstitution(context: { + atRuleCondition: AtRuleConditionShape | undefined + atStatementBoundary: boolean + /** The masked character right after the placeholder. */ + characterAfter: string | undefined + /** A "#" precedes the placeholder, past whitespace, with no other placeholder between. */ + followsHash: boolean + followsStatementBoundary: boolean + isJoinedToName: boolean + isJoinedToSelectorName: boolean + isPropertyNamePosition: boolean + isSelectorPosition: boolean + /** The masked non-whitespace character after the placeholder. */ + nextSignificant: string | undefined + onlyWhitespaceSinceLineStart: boolean + placeholderText: string + syntaxTextSinceBoundary: string +}): string { + const { placeholderText } = context + const replacementCharacter = getReplacementCharacter(context) + /** The fallback for a placeholder too short for its branch's fill. */ + const plainFill = () => fillPlaceholder(placeholderText, replacementCharacter) + + /** + * Whole-declaration (mixin) placeholder followed by a semicolon, for example + * `${'color: red'};`. Where the statement rule holds for what precedes it (a declaration + * boundary past whitespace and comments, or a previous placeholder the statement rule placed in + * block position), it becomes a dummy declaration ("$a:0" plus padding) so the SCSS parser does + * not flag the semicolon as unexpected, or "a:0" when the placeholder is too short for "$a:0". + * Otherwise there is nothing to anchor a dummy declaration to, so it is x-filled like an ordinary + * value instead: a declaration missing its own semicolon ("color: red\n${'green'};"), or the last + * line of a value split over lines ("border:\n ${width}\n ${style};"). + */ + if (replacementCharacter === ' ' && context.nextSignificant === ';') { + if (context.followsStatementBoundary) { + return ( + wrapPlaceholder(placeholderText, { open: '$a:0' }) ?? + wrapPlaceholder(placeholderText, { open: 'a:0' }) ?? + plainFill() + ) + } + return fillPlaceholder(placeholderText, 'x') + } + + /** + * Placeholder used as a property name, for example `${'color'}: red;`. Replaced with a fake + * property ("$a" plus padding) so the parser accepts it as a declaration. Inside a custom + * property's dashed name, for example `--${name}: 1px;`, the "--" prefix must stay intact, so it + * is x-filled instead. Joined to other name characters, for example `padding-${side}: 0;` or + * `${side}-top: 0;`, it becomes a Sass interpolation ("#{x}" plus padding), which the parser + * reads as part of the one property name around it and never lints as an unknown property. + */ + if (context.isPropertyNamePosition) { + if (isCustomPropertyName(context.syntaxTextSinceBoundary)) { + return fillPlaceholder(placeholderText, 'x') + } + if (context.isJoinedToName) { + return ( + wrapPlaceholder(placeholderText, { close: '}', open: '#{x' }) ?? + fillPlaceholder(placeholderText, 'x') + ) + } + return wrapPlaceholder(placeholderText, { open: '$a', padding: 'x' }) ?? plainFill() + } + + /** + * Placeholder used as a selector or component reference, followed by a pseudo-class/element + * and a rule body or parent-selector reference, for example `${FlipContainer}:hover & {`. + * Replaced with "&" (the nesting parent selector) plus padding so the compound selector shape + * survives. Joined to a name or its prefix, for example `&.${className}:hover {`, it is part of + * that name instead, so it is x-filled. + */ + if (context.isSelectorPosition) { + return context.isJoinedToSelectorName + ? fillPlaceholder(placeholderText, 'x') + : (wrapPlaceholder(placeholderText, { open: '&' }) ?? plainFill()) + } + + /** + * Placeholder standing for a condition in an `@media`, `@supports`, or `@container` prelude + * (getAtRuleConditionShape), for example `@media screen and ${query} {`. Replaced with a + * condition the parser accepts in that position: "(x" plus padding and ")" for a media or + * container condition, "x(" plus padding and ")" for a supports condition, which the parser + * reads as a function-shaped condition. + */ + if (context.atRuleCondition === 'parenthesized') { + return wrapPlaceholder(placeholderText, { close: ')', open: '(x' }) ?? plainFill() + } + if (context.atRuleCondition === 'function') { + return wrapPlaceholder(placeholderText, { close: ')', open: 'x(' }) ?? plainFill() + } + + /** + * Placeholder used as a hex color value, right after "#", for example `color: #${1};`. Replaced + * with "000" (a valid 3-digit hex color) plus padding. The three digits must stay on one line to + * read as a hex color, so they cover the placeholder's first three characters even when a line + * terminator is among them. + */ + if (context.followsHash) { + return placeholderText.length < HEX_FILL.length + ? plainFill() + : HEX_FILL + fillPlaceholder(placeholderText.slice(HEX_FILL.length), ' ') + } + + /** Ordinary property value, for example `color: ${'red'};`. */ + return plainFill() +} + +const HEX_FILL = '000' + +function getReplacementCharacter(context: { + atStatementBoundary: boolean + characterAfter: string | undefined + nextSignificant: string | undefined + onlyWhitespaceSinceLineStart: boolean +}): ' ' | '0' | 'x' { + /** + * Block position (docs/architecture.md, substitution invariants) is shaped like a whole + * declaration or mixin: after a statement boundary, for example `color: red; ${mixin}` or + * `&:hover { ${mixin} }` (the statement rule, `atStatementBoundary`), or alone on its own line, + * for example `${'color: red;'}` (the line rule). + */ + if ( + context.atStatementBoundary || + (context.onlyWhitespaceSinceLineStart && + !LINE_SLOT_EXCLUDED_NEXT.has(context.nextSignificant ?? '')) + ) { + return ' ' + } + + /** + * A numeric placeholder immediately before a "%" unit, for example `width: ${10}%;`, needs a + * digit so the value still parses as a percentage; every other placeholder (an ordinary + * property value, for example `color: ${'red'};`) is filled with a plain identifier character. + */ + return context.characterAfter === '%' ? '0' : 'x' +} + +/** + * The spans every scan in getTemplateSubstitutions walks: within [0, textLength], in ascending + * order, never overlapping, and never empty, so each lazy scan only moves forward and the output + * keeps the input length. The decorator supplies spans in exactly that shape (one per "${...}", + * left to right), so the common case pays one linear check and allocates nothing. A span list from + * "./api"'s getSubstitutions may be unsorted, overlapping, empty, or out of range: it pays for one + * copy and one sort, each span is clamped into the text, a span that is empty after clamping is + * dropped (it substitutes nothing), and overlapping spans merge into their union (spans that only + * touch stay separate, as adjacent placeholders do). The output depends only on span positions, + * never on their order, so nothing maps back to the caller's order. + */ +function normalizeSpans( + textLength: number, + spans: ReadonlyArray, +): ReadonlyArray { + if (isNormalized(textLength, spans)) { + return spans + } + const sorted = spans + .map((span) => { + const start = Math.max(0, Math.min(textLength, span.start)) + return { end: Math.max(start, Math.min(textLength, span.end)), start } + }) + .filter((span) => span.end > span.start) + .sort((left, right) => left.start - right.start || left.end - right.end) + const merged: SubstitutionSpan[] = [] + for (const span of sorted) { + const last = merged[merged.length - 1] + if (last && span.start < last.end) { + merged[merged.length - 1] = { end: Math.max(last.end, span.end), start: last.start } + } else { + merged.push(span) + } + } + return merged +} + +/** True when every span lies in [0, textLength], is not empty, and starts at or after the previous one's end. */ +function isNormalized(textLength: number, spans: ReadonlyArray): boolean { + let previousEnd = 0 + for (const span of spans) { + if (span.start < previousEnd || span.end <= span.start || span.end > textLength) { + return false + } + previousEnd = span.end + } + return true +} + +/** + * Builds the masked text from slices of `templateText` between spans plus a masked copy of each + * span's own characters, so a long template with few placeholders pays for the placeholders' + * length plus one slice per gap between them, never a per-character rewrite of the whole text. + * `spans` come from normalizeSpans. + */ +function maskSubstitutions(templateText: string, spans: ReadonlyArray): string { + let result = '' + let cursor = 0 + for (const span of spans) { + result += templateText.slice(cursor, span.start) + result += fillPlaceholder(templateText.slice(span.start, span.end), 'x') + cursor = span.end + } + result += templateText.slice(cursor) + return result +} + +/** + * Replaces every UTF-16 code unit of `placeholderText` with `replacementCharacter`, except a line + * terminator, which is kept so a multi-line placeholder keeps its line count (docs/architecture.md, + * substitution invariants). Indexed per code unit, not per code point (`for...of` would merge a + * surrogate pair into one iteration step and lose a unit of output length). + */ +function fillPlaceholder(placeholderText: string, replacementCharacter: string): string { + let result = '' + for (let index = 0; index < placeholderText.length; index++) { + const character = placeholderText[index] + result += isLineTerminator(character) ? character : replacementCharacter + } + return result +} + +/** + * True when a placeholder scanned as a property name sits inside a custom property's dashed name, + * for example `--${name}: 1px;`. `syntaxTextSinceBoundary` is the masked text from the nearest + * preceding declaration boundary (";", "{", "}", or the current line's start) to the placeholder, + * so a "--" left over from an earlier declaration on the same line never leaks in. + */ +function isCustomPropertyName(syntaxTextSinceBoundary: string): boolean { + return /^\s*--/.test(syntaxTextSinceBoundary) +} + +/** + * Same-length stand-in for `placeholderText`: `open`, then `padding` repeated, then `close`, + * written in order over its characters other than line terminators, which stay where they are. + * Undefined when the placeholder has fewer such characters than `open` and `close` together. + */ +function wrapPlaceholder( + placeholderText: string, + { close = '', open, padding = ' ' }: { close?: string; open: string; padding?: string }, +): string | undefined { + let slotCount = 0 + for (let index = 0; index < placeholderText.length; index++) { + slotCount += isLineTerminator(placeholderText[index]) ? 0 : 1 + } + const closeStart = slotCount - close.length + if (closeStart < open.length) { + return undefined + } + let result = '' + let slot = 0 + for (let index = 0; index < placeholderText.length; index++) { + const character = placeholderText[index] + if (isLineTerminator(character)) { + result += character + continue + } + result += + slot < open.length ? open[slot] : slot >= closeStart ? close[slot - closeStart] : padding + slot++ + } + return result +} + +/** A character right before a selector placeholder that makes the placeholder part of a name: `.${a}`, `#${a}`, `&${a}`, `:${a}`. */ +const SELECTOR_NAME_PREFIXES: ReadonlySet = new Set(['#', '&', '.', ':']) + +/** + * How a placeholder standing for an at-rule condition is filled: "parenthesized" for a media or + * container condition, "function" for a supports condition. + */ +type AtRuleConditionShape = 'function' | 'parenthesized' + +/** + * The at-rules whose preludes take conditions, matched where a statement starts, after its leading + * whitespace and comments (case-insensitive, as CSS matches at-keywords). Sticky, so it matches at + * `lastIndex` only. + */ +const CONDITION_AT_RULE_PATTERN = /@(container|media|supports)/iy + +type ConditionAtRule = 'container' | 'media' | 'supports' + +const CONDITION_AT_RULES: ReadonlySet = new Set([ + 'container', + 'media', + 'supports', +]) + +function isConditionAtRule(name: string): name is ConditionAtRule { + return CONDITION_AT_RULES.has(name) +} + +const CONDITION_COMBINATORS: ReadonlySet = new Set(['and', 'not', 'or']) + +/** A condition at-keyword that starts a statement, and the offset just past it. */ +interface ConditionKeyword { + readonly keyword: ConditionAtRule + readonly keywordEnd: number +} + +/** + * Returns a query for the condition at-keyword (`@media`, `@supports`, `@container`) that starts + * the statement at `statementStart`, after optional whitespace and comments, or undefined. The + * query remembers its last answer, so the many placeholders of one statement read its leading + * whitespace and comments once. + */ +function createConditionKeywordFinder( + syntaxText: string, +): (statementStart: number) => ConditionKeyword | undefined { + const pattern = new RegExp(CONDITION_AT_RULE_PATTERN) + const skipLeadingNonCode = createLeadingNonCodeSkipper(syntaxText) + let lastStart = -1 + let lastAnswer: ConditionKeyword | undefined + return (statementStart) => { + if (statementStart === lastStart) { + return lastAnswer + } + const keywordStart = skipLeadingNonCode(statementStart) + pattern.lastIndex = keywordStart + const match = pattern.exec(syntaxText) + const keyword = match?.[1]?.toLowerCase() + const keywordEnd = keywordStart + (match?.[0].length ?? 0) + lastStart = statementStart + lastAnswer = + keyword !== undefined && + isConditionAtRule(keyword) && + !isNameCharacter(syntaxText[keywordEnd]) + ? { keyword, keywordEnd } + : undefined + return lastAnswer + } +} + +/** + * Returns a query for the offset after the whitespace, block comments, and `//` line comments that + * start at `from`. An unterminated block comment is not skipped, so no at-keyword is found behind + * it. The search for a comment's closing "*\/" remembers its last range, as createRunScanner does, + * so a text full of statements that each open a comment reads each stretch once. + */ +function createLeadingNonCodeSkipper(text: string): (from: number) => number { + let closeQueryFrom = -1 + let closeFound = -2 + const findClose = (from: number): number => { + const isInsideLastRange = closeFound === -1 ? from >= closeQueryFrom : from <= closeFound + if (from >= closeQueryFrom && isInsideLastRange) { + return closeFound + } + closeQueryFrom = from + closeFound = text.indexOf('*/', from) + return closeFound + } + return (from) => { + let index = from + for (;;) { + while (index < text.length && isWhitespace(text[index])) { + index++ + } + if (text[index] !== '/') { + return index + } + if (text[index + 1] === '*') { + const close = findClose(index + 2) + if (close === -1) { + return index + } + index = close + 2 + } else if (text[index + 1] === '/') { + index += 2 + while (index < text.length && !isLineTerminator(text[index]) && text[index] !== '\f') { + index++ + } + } else { + return index + } + } + } +} + +/** + * Whether a placeholder starting at `placeholderStart` stands for a condition in the prelude that + * `conditionKeyword` opens, where anything but a condition is a parse error: after a separate + * `and`, `or`, or `not` in `@media`, `@supports`, or `@container`; right after `@supports`; or right + * after the container name in `@container name ${query}`; or right after `@media` and before a + * separate `or`, which follows only a condition, never a media type. Any other placeholder right + * after `@media` or `@container` stays an identifier, which already reads as a media type or a + * container name. The gap before the placeholder is whitespace and comments, read by the caller's + * CodeLookback (`previousSignificant`, the offset of the last code character before it); the word + * before that gap is read back to the at-keyword at most, and the word after the placeholder (from + * `nextSignificant`) only right after `@media`, so each stretch of text is read for at most the two + * placeholders after it. A line start inside a block comment counts as a statement start, so the + * at-keyword itself can sit inside a comment the CodeLookback skips (`/* a` then `@supports *\/` + * on the next line, before a placeholder), which leaves no code character before the placeholder + * at all. + */ +function getAtRuleConditionShape( + syntaxText: string, + { keyword, keywordEnd }: ConditionKeyword, + placeholder: { nextSignificant: number; previousSignificant: PreviousSignificant; start: number }, +): AtRuleConditionShape | undefined { + const conditionShape: AtRuleConditionShape = keyword === 'supports' ? 'function' : 'parenthesized' + if ( + placeholder.previousSignificant === INSIDE_NON_CODE || + placeholder.previousSignificant === NO_SIGNIFICANT_CHARACTER + ) { + return undefined + } + const wordEnd = Math.max(placeholder.previousSignificant + 1, keywordEnd) + if (wordEnd === placeholder.start) { + return undefined + } + if (wordEnd === keywordEnd) { + if (keyword === 'supports') { + return conditionShape + } + return keyword === 'media' && isWordAt(syntaxText, placeholder.nextSignificant, 'or') + ? conditionShape + : undefined + } + let wordStart = wordEnd + while (wordStart > keywordEnd && isNameCharacter(syntaxText[wordStart - 1])) { + wordStart-- + } + if (CONDITION_COMBINATORS.has(syntaxText.slice(wordStart, wordEnd).toLowerCase())) { + return conditionShape + } + const isContainerName = + keyword === 'container' && + wordStart < wordEnd && + skipWhitespaceBackward(syntaxText, wordStart, keywordEnd) === keywordEnd + return isContainerName ? conditionShape : undefined +} + +/** True when `word` starts at `index`, compared case-insensitively, with no name character after it. */ +function isWordAt(text: string, index: number, word: string): boolean { + return ( + text.slice(index, index + word.length).toLowerCase() === word && + !isNameCharacter(text[index + word.length]) + ) +} + +/** The offset after the last non-whitespace character in [floor, from), or `floor` when there is none. */ +function skipWhitespaceBackward(text: string, from: number, floor: number): number { + let index = from + while (index > floor && isWhitespace(text[index - 1])) { + index-- + } + return index +} diff --git a/src/test/substituter.test.ts b/src/test/substituter.test.ts deleted file mode 100644 index 718c953..0000000 --- a/src/test/substituter.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// @ts-check -import { assert } from 'chai'; -import 'mocha'; -import { getSubstitutions } from '../_substituter'; - -describe('substituter', () => { - it('should replace property value with x', () => { - assert.deepEqual( - performSubstitutions([ - 'width: 1px;', - `color: \${'red'};`, - 'color: red;', - ].join('\n')), - [ - 'width: 1px;', - `color: xxxxxxxx;`, - 'color: red;', - ].join('\n') - ); - }); - - it('should insert whitespace when placeholder is used a entire property', () => { - assert.deepEqual( - performSubstitutions([ - 'width: 1px;', - `\${'color: red;'}`, - 'color: red;', - ].join('\n')), - [ - 'width: 1px;', - ` `, - 'color: red;', - ].join('\n') - ); - }); - - it('should insert a false property when placeholder is used a entire property with trailing semi-colon', () => { - assert.deepEqual( - performSubstitutions([ - 'width: 1px;', - `\${'color: red'};`, - 'color: red;', - ].join('\n')), - [ - 'width: 1px;', - `$a:0 ;`, - 'color: red;', - ].join('\n') - ); - }); - - it('should add a zero for percent units', () => { - assert.deepEqual( - performSubstitutions( - 'width: ${10}%;' - ), - 'width: 00000%;' - ); - }); - - it('should replace property with fake proeprty when placeholder is used in name (#52)', () => { - assert.deepEqual( - performSubstitutions([ - 'width: 1px;', - `\${123}: 1px;`, - 'color: red;', - ].join('\n')), - [ - 'width: 1px;', - `$axxxx: 1px;`, - 'color: red;', - ].join('\n') - ); - }); - - it('should insert x for placeholder used as rule', () => { - assert.deepEqual( - performSubstitutions([ - '${"button"} {', - 'color: ${"red"};', - '}', - ].join('\n')), - [ - 'xxxxxxxxxxx {', - 'color: xxxxxxxx;', - '}', - ].join('\n') - ); - }); - - it('should insert x for placeholder used as part of a rule (#59)', () => { - assert.deepEqual( - performSubstitutions([ - '${"button"}, ${"a"} {', - 'color: ${"red"};', - '}', - ].join('\n')), - [ - 'xxxxxxxxxxx, xxxxxx {', - 'color: xxxxxxxx;', - '}', - ].join('\n') - ); - }); - - it('should fake out property name when inside nested rule (#54)', () => { - assert.deepEqual( - performSubstitutions([ - '&.buu-foo {', - ' \${"baseShape"};', - ' &.active {', - ' font-size: 2rem;', - ' }', - '}', - ].join('\n')), - [ - '&.buu-foo {', - ' $a:0 ;', - ' &.active {', - ' font-size: 2rem;', - ' }', - '}', - ].join('\n') - ); - }); - - it('should add zeros for color units (#60)', () => { - assert.deepEqual( - performSubstitutions( - 'color: #${1};' - ), - 'color: #000 ;' - ); - }); - - it('should replace adjacent variables with x (#62)', () => { - assert.deepEqual( - performSubstitutions([ - `margin: \${'1px'}\${'1px'};`, - `padding: \${'1px'} \${'1px'};`, - ].join('\n')), - [ - `margin: xxxxxxxxxxxxxxxx;`, - `padding: xxxxxxxx xxxxxxxx;`, - ].join('\n') - ); - }); - - it('should replace placeholder that spans multiple lines with x (#44)', () => { - assert.deepEqual( - performSubstitutions([ - 'background:', - ` $\{'transparent'};`, - ].join('\n')), - [ - 'background:', - ' xxxxxxxxxxxxxxxx;', - ].join('\n') - ); - }); - - it('should replace placeholder used in contextual selector (#71)', () => { - assert.deepEqual( - performSubstitutions([ - 'position: relative;', - '', - '${FlipContainer}:hover & {', - ' transform: rotateY(180deg);', - '}', - ].join('\n')), - [ - 'position: relative;', - '', - '& :hover & {', - ' transform: rotateY(180deg);', - '}', - ].join('\n') - ); - }); - - it('should replace placeholder used in child selector (#75)', () => { - assert.deepEqual( - performSubstitutions([ - 'position: relative;', - '> ${FlipContainer}:hover {', - ' color: red;', - '}', - ].join('\n')), - [ - 'position: relative;', - '> & :hover {', - ' color: red;', - '}', - ].join('\n') - ); - }); -}); - -function performSubstitutions(value: string) { - return getSubstitutions(value, getSpans(value)); -} - -function getSpans(value: string) { - const spans: Array<{ start: number, end: number }> = []; - const re = /(\$\{[^}]*\})/g; - let match: RegExpExecArray | null = re.exec(value); - while (match) { - spans.push({ start: match.index, end: match.index + match[0].length }); - match = re.exec(value); - } - return spans; -} diff --git a/src/_config.ts b/src/tsserver/plugin-identity.ts similarity index 66% rename from src/_config.ts rename to src/tsserver/plugin-identity.ts index 6332546..ee92fba 100644 --- a/src/_config.ts +++ b/src/tsserver/plugin-identity.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -export const pluginName = 'ts-styled-plugin'; \ No newline at end of file +export const pluginIdentity = 'ts-styled-plugin' diff --git a/src/tsserver/tsserver-logger.ts b/src/tsserver/tsserver-logger.ts new file mode 100644 index 0000000..09be54a --- /dev/null +++ b/src/tsserver/tsserver-logger.ts @@ -0,0 +1,12 @@ +import type { Logger } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' + +import { pluginIdentity } from './plugin-identity.ts' + +export class TsServerLogger implements Logger { + public constructor(private readonly pluginInfo: ts.server.PluginCreateInfo) {} + + public log(message: string) { + this.pluginInfo.project.projectService.logger.info(`[${pluginIdentity}] ${message}`) + } +} diff --git a/src/tsserver/tsserver-plugin.ts b/src/tsserver/tsserver-plugin.ts new file mode 100644 index 0000000..aff19e9 --- /dev/null +++ b/src/tsserver/tsserver-plugin.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { decorateWithTemplateLanguageService } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' + +import { describeValue, PluginConfigurationManager } from '../configuration/plugin-configuration.ts' +import { StyledTemplateLanguageService } from '../template-language-service.ts' +import { getTemplateSettings } from '../template-settings.ts' +import { StyledVirtualDocumentProvider } from '../virtual-document/styled-virtual-document-provider.ts' +import { TsServerLogger } from './tsserver-logger.ts' + +export class TsServerStyledPlugin { + private logger?: TsServerLogger + private readonly configurationManager = new PluginConfigurationManager() + + public constructor(private readonly typescript: typeof ts) {} + + public create(info: ts.server.PluginCreateInfo): ts.LanguageService { + const logger = new TsServerLogger(info) + this.logger = logger + this.applyConfiguration(info.config, logger) + + if (!isSupportedTypeScriptVersion(this.typescript)) { + logger.log( + `Unsupported TypeScript version ${this.typescript.version} detected. TypeScript 5.0 or newer required; the plugin is disabled for this host.`, + ) + return info.languageService + } + + /** + * Passing the tsserver-backed logger here (rather than the languageServiceFactory overload) + * is what lets StyledTemplateLanguageService log a recovered exception to the real tsserver + * log; emmetCompletionProvider stays at its default. + */ + const templateLanguageService = new StyledTemplateLanguageService( + this.typescript, + this.configurationManager, + new StyledVirtualDocumentProvider(this.typescript), + logger, + ) + + return decorateWithTemplateLanguageService( + this.typescript, + info.languageService, + info.project, + templateLanguageService, + getTemplateSettings(this.configurationManager), + { logger }, + ) + } + + public onConfigurationChanged(config: unknown) { + const logger = this.logger + logger?.log('onConfigurationChanged') + this.applyConfiguration(config, logger) + } + + private applyConfiguration(config: unknown, logger: TsServerLogger | undefined) { + this.configurationManager.updateFromPluginConfig(config, logger) + logger?.log('config: ' + describeValue(this.configurationManager.config)) + } +} + +/** The floor and the reason for it: docs/tsserver-host.md, "TypeScript versions". */ +export function isSupportedTypeScriptVersion(typescript: Pick): boolean { + const major = Number.parseInt(typescript.version, 10) + return major >= 5 +} diff --git a/src/virtual-document/css-code-scanner.ts b/src/virtual-document/css-code-scanner.ts new file mode 100644 index 0000000..ff76c42 --- /dev/null +++ b/src/virtual-document/css-code-scanner.ts @@ -0,0 +1,585 @@ +/** + * The boundary scanner and the character predicates of CSS tokenization that every structural + * reader of template text shares (docs/architecture.md, "Virtual document", the boundary scanner). + */ + +export const LINE_SEPARATOR_CODE = 0x2028 +export const PARAGRAPH_SEPARATOR_CODE = 0x2029 + +/** + * Built with String.fromCharCode rather than an escape sequence in source text, so the line + * separator and paragraph separator code points never appear as raw characters in any file. + */ +export const LINE_SEPARATOR = String.fromCharCode(LINE_SEPARATOR_CODE) +export const PARAGRAPH_SEPARATOR = String.fromCharCode(PARAGRAPH_SEPARATOR_CODE) + +/** + * A code unit the CSS scanner accepts inside an identifier (vscode-css-languageservice's + * `_identChar`): ASCII letters, digits, "-", "_", and every code unit from U+0080 up, except U+2028 + * and U+2029, which are line terminators here (the virtual document turns them into "\n"). + */ +export function isNameCodeUnit(code: number): boolean { + return ( + (code >= 0x61 && code <= 0x7a) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x30 && code <= 0x39) || + code === 0x2d || + code === 0x5f || + (code >= 0x80 && code !== LINE_SEPARATOR_CODE && code !== PARAGRAPH_SEPARATOR_CODE) + ) +} + +export function isNameCharacter(character: string | undefined): boolean { + return character !== undefined && isNameCodeUnit(character.charCodeAt(0)) +} + +export function isHexDigit(code: number): boolean { + return ( + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x46) || + (code >= 0x61 && code <= 0x66) + ) +} + +/** The end of the run of at most `maxDigits` hex digits starting at `start`. */ +export function hexDigitsEnd(text: string, start: number, maxDigits: number): number { + let end = start + while (end - start < maxDigits && isHexDigit(text.charCodeAt(end))) { + end++ + } + return end +} + +/** The characters that start a new line for position mapping: "\n", "\r", U+2028, and U+2029. */ +export function isLineTerminator(character: string | undefined): boolean { + return ( + character === '\n' || + character === '\r' || + character === LINE_SEPARATOR || + character === PARAGRAPH_SEPARATOR + ) +} + +/** + * A line break in the sense the CSS tokenizer uses to end an unterminated string or a "//" comment + * and to close an escape: "\n", "\r", or "\f". U+2028 and U+2029 do not count, since the CSS scanner + * reads them as ordinary characters: inside a string they stay verbatim, ordinary content. + */ +function isLineBreak(character: string | undefined): boolean { + return character === '\n' || character === '\r' || character === '\f' +} + +/** + * A line break outside a string, where a "//" line comment ends and a backslash escapes nothing: + * a CSS line break or a Unicode line or paragraph separator, which the virtual document turns + * into "\n" before the CSS scanner sees it. + */ +function isCodeLineBreak(character: string | undefined): boolean { + return isLineBreak(character) || isLineTerminator(character) +} + +/** The whitespace the CSS scanner reads between tokens outside strings: space, tab, and its line breaks. */ +export function isCssWhitespace(character: string | undefined): boolean { + return character === ' ' || character === '\t' || isCodeLineBreak(character) +} + +/** True when a backslash before `character`, outside a string, escapes it: it exists and is no line break. */ +export function isEscapableCharacter(character: string | undefined): boolean { + return character !== undefined && !isCodeLineBreak(character) +} + +/** A code character that ends or opens a statement: ";", "{", or "}". */ +export function isStatementBoundary(character: string | undefined): boolean { + return character === ';' || character === '{' || character === '}' +} + +const WHITESPACE_PATTERN = /\s/ + +/** The `\s` class, leaving only non-ASCII characters to the regular expression. */ +export function isWhitespace(character: string | undefined): boolean { + if (character === undefined) { + return false + } + const code = character.charCodeAt(0) + if (code < 0x80) { + return code === 0x20 || (code >= 0x09 && code <= 0x0d) + } + return WHITESPACE_PATTERN.test(character) +} + +/** + * Scanner state nonCodeEnd carries between positions: the unquoted url() argument open at the + * current position, from the start of its function name through the closing ")". Every character + * while it is open is url content to a structural reader: a ";", "{", or "}" there is part of the + * url token. + */ +export interface CssCodeScanState { + url?: UnquotedUrlArgument +} + +interface UnquotedUrlArgument { + /** Where a block comment can open: right after "(", or after the last string or comment. */ + commentAllowedAt: number + /** Where the last escape ended: whitespace an escape took is no token boundary. */ + escapeEnd: number + readonly openParenthesis: number +} + +export function createCssCodeScanState(): CssCodeScanState { + return {} +} + +/** + * The comment, string, escape, and url() boundary scanner (docs/architecture.md, "Virtual + * document"). It updates `state` for the character at `index`, then returns the end (exclusive) of + * the run starting there that the caller must step over as a unit, or -1 when `index` is plain + * code. A run is a block comment, a "//" line comment, a quoted string, or an escaped run, which is + * code: the rest of the identifier an escape outside a string starts or continues, or inside an + * unquoted url() argument the escape alone. Call it in order at every code position where it can + * act (createScanCandidatePattern); a caller may skip a stretch of code holding none of those, + * since the calls there would return -1 and change nothing. + */ +export function nonCodeEnd(text: string, index: number, state: CssCodeScanState): number { + const character = text[index] + if (character === '"' || character === "'") { + const end = stringEnd(text, index, character) + if (state.url) { + state.url.commentAllowedAt = end + } + return end + } + const isEscape = character === '\\' && startsCodeEscape(text, index) + if (state.url) { + return urlArgumentRunEnd(text, index, state, state.url, isEscape) + } + if (character === '/') { + return commentEnd(text, index) + } + if (!isEscape && character !== 'u' && character !== 'U') { + return -1 + } + const startsIdentifier = !continuesToken(text[index - 1]) + const urlParenthesis = startsIdentifier ? findUrlArgumentParenthesis(text, index) : -1 + if (urlParenthesis !== -1) { + state.url = { + commentAllowedAt: urlParenthesis + 1, + escapeEnd: -1, + openParenthesis: urlParenthesis, + } + } + return isEscape ? identifierEnd(text, index) : -1 +} + +/** + * nonCodeEnd inside an unquoted url() argument, which the CSS parser reads as trivia, one token, + * then trivia again: a block comment opens only where a token could start (right after "(", after + * a string or comment, or after whitespace no escape took), and "/" or "*" next to url characters + * is url content. "//" opens no comment here (SCSSScanner.comment checks `!this.inURL`). An + * escape steps over its own characters; an unescaped ")" closes the argument. + */ +function urlArgumentRunEnd( + text: string, + index: number, + state: CssCodeScanState, + url: UnquotedUrlArgument, + isEscape: boolean, +): number { + const character = text[index] + if (isEscape) { + url.escapeEnd = escapeEnd(text, index, isCodeLineBreak) + return url.escapeEnd + } + if (character === ')') { + state.url = undefined + return -1 + } + const opensComment = + character === '/' && + text[index + 1] === '*' && + (index === url.commentAllowedAt || + (index !== url.escapeEnd && isCssWhitespace(text[index - 1]))) + if (!opensComment) { + return -1 + } + const end = commentEnd(text, index) + url.commentAllowedAt = end + return end +} + +/** + * The positions where nonCodeEnd can act, a superset: a quote or "/" can start a string or + * comment, a backslash starts an escape, ")" closes an unquoted url() argument, and a "u" followed + * by "r" or by an escape can start the name `url` that opens one. Matched case-insensitively, as + * CSS matches the url() function name. + */ +const SCAN_STATE_CANDIDATE_PATTERN = /["'/)\\]|u[r\\]/gi + +/** + * The stretches of a text where a structural character (";", "{", "}", "&", a line terminator) + * decides nothing, in ascending order and never overlapping: comments, strings, escaped runs, and + * unquoted url() arguments (from the url function name through its ")", or to the end of the text + * when unclosed). A "//" comment's run ends before the line terminator that ends it, which is a + * line break at code. `isComment` tells a comment, which a reader of the line's last code + * character reads past, from the rest, which are significant characters. + */ +export interface NonCodeRuns { + readonly ends: readonly number[] + readonly isComment: readonly boolean[] + readonly starts: readonly number[] +} + +const NON_CODE_RUN_CANDIDATE_PATTERN = new RegExp(SCAN_STATE_CANDIDATE_PATTERN) + +/** + * Finds a text's NonCodeRuns with one forward walk that calls nonCodeEnd only at the positions + * where it can act, so a text of plain code costs one search that finds few or no candidates. + */ +export function findNonCodeRuns(text: string): NonCodeRuns { + const starts: number[] = [] + const ends: number[] = [] + const isComment: boolean[] = [] + const state = createCssCodeScanState() + const candidates = NON_CODE_RUN_CANDIDATE_PATTERN + let urlStart = -1 + let cursor = 0 + for (;;) { + candidates.lastIndex = cursor + const index = candidates.exec(text)?.index + if (index === undefined) { + break + } + const wasInUrl = state.url !== undefined + const end = nonCodeEnd(text, index, state) + cursor = end === -1 ? index + 1 : end + if (!wasInUrl && state.url !== undefined) { + urlStart = index + } + if (wasInUrl || state.url !== undefined) { + if (state.url === undefined) { + starts.push(urlStart) + ends.push(index + 1) + isComment.push(false) + } + continue + } + if (end === -1) { + continue + } + const opensComment = text[index] === '/' + const endsAtLineTerminator = + opensComment && text[index + 1] === '/' && isLineTerminator(text[end - 1]) + starts.push(index) + ends.push(endsAtLineTerminator ? end - 1 : end) + isComment.push(opensComment) + } + if (state.url !== undefined) { + starts.push(urlStart) + ends.push(text.length) + isComment.push(false) + } + return { ends, isComment, starts } +} + +/** + * A global, case-insensitive pattern matching every position where nonCodeEnd can act, plus each + * of `characters` (character-class members that need no escaping), for a reader that jumps from + * one candidate to the next with `lastIndex` instead of visiting every character. + */ +export function createScanCandidatePattern(characters: string): RegExp { + return new RegExp(`${SCAN_STATE_CANDIDATE_PATTERN.source}|[${characters}]`, 'gi') +} + +/** An offset into the text a CodeLookback reads. */ +type TextOffset = number & { readonly brand: 'TextOffset' } + +/** CodeLookback.previousSignificant's answer when no significant character precedes the query. */ +export const NO_SIGNIFICANT_CHARACTER = -1 + +/** CodeLookback.previousSignificant's answer when the queried offset lies inside a comment or a string. */ +export const INSIDE_NON_CODE = -2 + +export type PreviousSignificant = + | TextOffset + | typeof NO_SIGNIFICANT_CHARACTER + | typeof INSIDE_NON_CODE + +/** + * Finds the nearest significant character before an offset: the last non-whitespace code + * character, where a comment is skipped, a string counts as one significant character at its + * opening quote, an escaped run at its backslash, and an unquoted url() argument at its "(" (a + * query inside the argument answers that "("). Walks forward with nonCodeEnd from `start`, + * remembering where it stopped, so a series of queries at increasing offsets costs one pass over + * the text in total; a query below the previous one starts over from `start`. + */ +export interface CodeLookback { + /** The offset of that character in [start, before), NO_SIGNIFICANT_CHARACTER, or INSIDE_NON_CODE. */ + previousSignificant(before: number): PreviousSignificant +} + +function toTextOffset(index: number): TextOffset { + return index as TextOffset +} + +export function createCodeLookback(text: string, start: number): CodeLookback { + let state = createCssCodeScanState() + const candidates = new RegExp(SCAN_STATE_CANDIDATE_PATTERN) + let cursor = start + let lastSignificant: PreviousSignificant = NO_SIGNIFICANT_CHARACTER + /** The first candidate at or after `cursor` (text.length when none), kept so queries between two candidates search once. */ + let nextCandidate = -1 + /** The comment or string starting at runStart, kept so repeated queries inside it scan it once. */ + let runStart = -1 + let runEnd = -1 + + /** True when `index` lies inside an open unquoted url() argument, past its "(". */ + const isInsideUrlArgument = (index: number) => + state.url !== undefined && index > state.url.openParenthesis + + /** Records the last non-whitespace character in [from, to), all of it plain code, up to an open url() argument's "(". */ + const skipPlainCode = (from: number, to: number) => { + const end = state.url ? Math.min(to, state.url.openParenthesis + 1) : to + for (let index = end - 1; index >= from; index--) { + if (!isWhitespace(text[index])) { + lastSignificant = toTextOffset(index) + return + } + } + } + + return { + previousSignificant(before) { + if (before < cursor) { + state = createCssCodeScanState() + cursor = start + lastSignificant = NO_SIGNIFICANT_CHARACTER + nextCandidate = -1 + } + while (cursor < before) { + if (nextCandidate < cursor) { + candidates.lastIndex = cursor + nextCandidate = candidates.exec(text)?.index ?? text.length + } + if (nextCandidate >= before) { + skipPlainCode(cursor, before) + cursor = before + break + } + skipPlainCode(cursor, nextCandidate) + cursor = nextCandidate + const end = cursor === runStart ? runEnd : nonCodeEnd(text, cursor, state) + const insideUrl = isInsideUrlArgument(cursor) + if (end === -1) { + lastSignificant = insideUrl ? lastSignificant : toTextOffset(cursor) + cursor++ + continue + } + runStart = cursor + runEnd = end + const isEscapedRun = text[cursor] === '\\' + if (end > before || (end === before && isUnterminatedRun(text, cursor, end))) { + /** An escaped run is code, so a query inside one sees its own first character (or its url() argument's "("), left uncommitted so a later query at `cursor` still sees what precedes it. */ + if (!isEscapedRun) { + return INSIDE_NON_CODE + } + return insideUrl ? lastSignificant : toTextOffset(cursor) + } + if (!insideUrl && (isEscapedRun || text[cursor] === '"' || text[cursor] === "'")) { + lastSignificant = toTextOffset(cursor) + } + cursor = end + } + return lastSignificant + }, + } +} + +/** + * True when the comment or string run [start, end) nonCodeEnd returned has no closing delimiter, + * so a caret at its end still types into it: a string that ended at a line break or the end of the + * text rather than at its matching quote, a block comment with no closing star and slash, or a line + * comment the text ends inside. A string's closing quote is its last character with an even count + * of backslashes before it: those backslashes pair up into escaped backslashes. + */ +function isUnterminatedRun(text: string, start: number, end: number): boolean { + const first = text[start] + if (first === '/') { + return text[start + 1] === '*' + ? end - start < 4 || text[end - 2] !== '*' || text[end - 1] !== '/' + : !isCodeLineBreak(text[end - 1]) + } + if (first !== '"' && first !== "'") { + return false + } + if (end - 1 <= start || text[end - 1] !== first) { + return true + } + let backslashes = 0 + while (end - 2 - backslashes > start && text[end - 2 - backslashes] === '\\') { + backslashes++ + } + return backslashes % 2 === 1 +} + +/** An unterminated block comment runs to the end of the text; a line comment ends after its terminator. -1 when `index` opens neither. */ +export function commentEnd(text: string, index: number): number { + const next = text[index + 1] + if (next === '*') { + const close = text.indexOf('*/', index + 2) + return close === -1 ? text.length : close + 2 + } + if (next === '/') { + let end = index + 2 + while (end < text.length && !isCodeLineBreak(text[end])) { + end++ + } + return end < text.length ? end + 1 : end + } + return -1 +} + +/** + * A string ends after its matching quote, or before an unescaped line break (isLineBreak), which is + * then code again. A backslash starts an escape (escapeEnd). + */ +function stringEnd(text: string, index: number, quote: string): number { + let end = index + 1 + while (end < text.length) { + const character = text[end] + if (isLineBreak(character)) { + return end + } + if (character === '\\') { + end = escapeEnd(text, end, isLineBreak) + continue + } + end++ + if (character === quote) { + return end + } + } + return end +} + +/** The most hex digits a CSS escape reads. */ +export const MAX_CSS_HEX_DIGITS = 6 + +/** + * The end (exclusive) of the escape whose backslash is at `index`, as vscode-css-languageservice's + * scanner reads one: up to six hex digits plus one optional space, tab, or line break after them; + * otherwise the one character after the backslash, where a line break is a whole `\r\n` pair. + * `isBreak` names the line breaks of the text being scanned: isLineBreak inside a string (which + * keeps U+2028 and U+2029 verbatim), isCodeLineBreak outside one. + */ +function escapeEnd( + text: string, + index: number, + isBreak: (character: string | undefined) => boolean, +): number { + const end = hexDigitsEnd(text, index + 1, MAX_CSS_HEX_DIGITS) + if (end > index + 1) { + const next = text[end] + return next === ' ' || next === '\t' ? end + 1 : lineBreakEnd(text, end, isBreak) + } + if (end >= text.length) { + return end + } + return isBreak(text[end]) ? lineBreakEnd(text, end, isBreak) : end + 1 +} + +/** The end of the line break at `index` (a `\r\n` pair counts as one), or `index` when there is none. */ +function lineBreakEnd( + text: string, + index: number, + isBreak: (character: string | undefined) => boolean, +): number { + const character = text[index] + if (character === '\r' && text[index + 1] === '\n') { + return index + 2 + } + return isBreak(character) ? index + 1 : index +} + +/** + * True when the backslash at `index`, outside a string or comment, starts an escape: the CSS + * scanner escapes no line break there, and in the virtual document a backslash at the end of the + * template text is followed by the closing wrapper's line break, so it escapes nothing either. + */ +export function startsCodeEscape(text: string, index: number): boolean { + return isEscapableCharacter(text[index + 1]) +} + +/** + * The end of the identifier continuing at `index`: name characters and escapes, as the CSS + * scanner joins them into one identifier token. + */ +export function identifierEnd(text: string, index: number): number { + let end = index + while (end < text.length) { + if (text[end] === '\\' && startsCodeEscape(text, end)) { + end = escapeEnd(text, end, isCodeLineBreak) + } else if (isNameCodeUnit(text.charCodeAt(end))) { + end++ + } else { + break + } + } + return end +} + +/** Longest identifier that opens an unquoted url() argument: `url-prefix`. */ +const MAX_URL_FUNCTION_NAME_LENGTH = 'url-prefix'.length + +/** + * The offset of the "(" after the identifier starting at `index` when that identifier names a + * function whose argument the CSS parser reads as an unquoted URL: its name, with escapes decoded + * and compared case-insensitively, is `url` or `url-prefix`, and "(" follows it directly. So + * `\75 rl(` opens one and `image-url(` does not; -1 otherwise. The caller has checked that `index` + * starts an identifier. + */ +function findUrlArgumentParenthesis(text: string, index: number): number { + let name = '' + let end = index + while (end < text.length && name.length <= MAX_URL_FUNCTION_NAME_LENGTH) { + if (text[end] === '\\' && startsCodeEscape(text, end)) { + const next = escapeEnd(text, end, isCodeLineBreak) + name += decodeEscape(text, end) + end = next + } else if (isNameCodeUnit(text.charCodeAt(end))) { + name += text[end] + end++ + } else { + break + } + } + return text[end] === '(' && namesUrlFunction(name) ? end : -1 +} + +/** + * True when `name`, compared case-insensitively, is `url` or `url-prefix`: a function whose + * argument the CSS parser reads as an unquoted URL. + */ +export function namesUrlFunction(name: string): boolean { + const lowerName = name.toLowerCase() + return lowerName === 'url' || lowerName === 'url-prefix' +} + +/** + * The character an escape whose backslash is at `index` stands for, as the CSS scanner decodes it: + * a hex escape's code unit (none for zero), otherwise the escaped character itself. + */ +function decodeEscape(text: string, index: number): string { + const end = hexDigitsEnd(text, index + 1, MAX_CSS_HEX_DIGITS) + if (end === index + 1) { + return text[end] ?? '' + } + const value = parseInt(text.slice(index + 1, end), 16) + return value === 0 ? '' : String.fromCharCode(value) +} + +/** + * True when a character before a name makes that name part of a longer token, so it starts no + * identifier: a name character, or the "#" of a hash or "@" of an at-keyword. + */ +function continuesToken(character: string | undefined): boolean { + return character === '#' || character === '@' || isNameCharacter(character) +} diff --git a/src/virtual-document/javascript-escapes.ts b/src/virtual-document/javascript-escapes.ts new file mode 100644 index 0000000..0c8f866 --- /dev/null +++ b/src/virtual-document/javascript-escapes.ts @@ -0,0 +1,959 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' + +import { + hexDigitsEnd, + isCssWhitespace, + isEscapableCharacter, + isHexDigit, + isNameCharacter, + isNameCodeUnit, + LINE_SEPARATOR, + LINE_SEPARATOR_CODE, + MAX_CSS_HEX_DIGITS, + namesUrlFunction, + PARAGRAPH_SEPARATOR, + PARAGRAPH_SEPARATOR_CODE, +} from './css-code-scanner.ts' + +/** + * The template-relative span [start, end) of the raw text one stand-in covers: a run of adjacent + * escapes (the escaped character after the run included when the stand-in absorbs it), or every run + * inside one name together with the name characters before and between them, when the padding + * moved in front of the name. + */ +export interface EscapeRun { + readonly end: number + readonly start: number +} + +interface TemplateCss { + readonly escapeRuns: readonly EscapeRun[] + readonly text: string +} + +const cssByContext = new WeakMap, TemplateCss>() + +function getTemplateCss(context: Pick): TemplateCss { + const cached = cssByContext.get(context) + if (cached !== undefined) { + return cached + } + const escapeRuns: EscapeRun[] = [] + const css = { escapeRuns, text: replaceEscapes(context.text, escapeRuns) } + cssByContext.set(context, css) + return css +} + +/** + * `context.text` with JavaScript escapes replaced (replaceJavaScriptEscapes), the one text every + * reader of a template's CSS structure uses after substitution: the virtual document, the + * value-shape test, and the stray closing brace search. Kept per TemplateContext, as the value + * wrapper is, so a request that reaches several of them replaces the escapes once. + */ +export function getTemplateCssText(context: Pick): string { + return getTemplateCss(context).text +} + +/** + * The escape runs of `context.text`, in order and never overlapping, from the same pass as + * getTemplateCssText: a span mapped back from the virtual document is widened to whole runs. + */ +export function getTemplateEscapeRuns( + context: Pick, +): readonly EscapeRun[] { + return getTemplateCss(context).escapeRuns +} + +/** + * The template text the plugin receives is the raw source between the backticks, with every + * JavaScript escape sequence as written, while styled-components receives the cooked strings, with + * each escape already resolved to the character it stands for. This replaces each escape with a + * stand-in of the same length that reads, to the CSS scanner, like its cooked character + * (docs/architecture.md, Virtual document). + * + * A run of adjacent escapes is cooked as a whole, then written right-aligned in its own span with + * spaces before it: a cooked backslash then still escapes the character after the run, as it does + * at runtime, and a surrogate pair written as two escapes stays one pair. When the cooked run ends + * inside a CSS escape (a backslash that escapes what follows, or a hex escape hex digits after the + * run continue), the padding goes inside or before that CSS escape instead where CSS allows it + * (joinOpenEscape, joinEscapedCharacter), so it does not split a name, url(), or the escape. Otherwise, + * inside an unquoted url() argument, the padding is URL_PADDING placed where it joins the url token + * (splitUrlStandIn). A cooked quote opening the argument's first content (isFreshUrlArgument) makes + * the whole argument a quoted string instead, so it is read as plain value text, not url content. When + * the run continues a name, the spaces go in front of the whole name (findNameStart), or, where + * whitespace there would split a larger token, into a CSS hex escape (hexEscapeLastCharacter). A + * cooked control character or Unicode line or paragraph separator is written as a space. An invalid + * escape (the cooked value is `undefined` at runtime) is left as written. Returns `text` itself when + * it holds no backslash. + */ +export function replaceJavaScriptEscapes(text: string): string { + return replaceEscapes(text, undefined) +} + +/** + * A name whose stand-in is still being written: `padding` spaces, then `body` (the raw name + * characters before the first escape run, then each run's cooked text and the raw name characters + * between runs), covering the raw span [start, end). Kept open so a later run in the same name adds + * its padding to the same spaces in front instead of splitting the name. + */ +interface OpenName { + body: string + end: number + /** The last run's cooked text ends with a backslash that escapes the raw character at `end`. */ + escapesNext: boolean + /** The last character of `body`, taken from the stand-in that ends it, so `body`, built by concatenation, is never indexed (reading a character flattens it). */ + lastCharacter: string | undefined + padding: number + readonly start: number +} + +/** replaceJavaScriptEscapes, also appending each replaced run's span to `escapeRuns` when given. */ +function replaceEscapes(text: string, escapeRuns: EscapeRun[] | undefined): string { + let runStart = text.indexOf('\\') + if (runStart === -1) { + return text + } + + let result = '' + let copiedUntil = 0 + let openName: OpenName | undefined + /** + * The last character writeStandIn wrote, which ends `result` whenever no name is open and the + * next run starts a name right where the text written so far ends. Kept apart so `result`, a + * string built by concatenation, is never indexed (reading a character flattens it). + */ + let lastStandInCharacter: string | undefined + const closeName = () => { + if (openName) { + result += ' '.repeat(openName.padding) + openName.body + escapeRuns?.push({ end: openName.end, start: openName.start }) + openName = undefined + } + } + const writeStandIn = (start: number, end: number, standIn: string) => { + closeName() + result += text.slice(copiedUntil, start) + standIn + lastStandInCharacter = standIn[standIn.length - 1] + escapeRuns?.push({ end, start }) + } + + /** The previous run's answers carried to `copiedUntil`, where it ended (isInsideUnquotedUrl). */ + const readBack: UrlReadBack = { floor: 0, insideUrl: false, openEscape: undefined } + while (runStart !== -1) { + const run = readRun(text, runStart) + if (run === undefined) { + /** An invalid escape: its backslash and the character after it stay as written. */ + runStart = text.indexOf('\\', runStart + 2) + continue + } + const { cooked, end: runEnd } = run + const padding = runEnd - runStart - cooked.length + const escapeStart = openEscapeStart(cooked) + const escapesNext = escapeStart !== -1 && escapeStart === cooked.length - 1 + readBack.floor = copiedUntil + const rawInsideUrl = isInsideUnquotedUrl(text, runStart, readBack) + /** + * A cooked quote opening the argument's first content makes the whole argument a quoted + * string, not an unquoted one: `url(\x22\x22)` cooks to `url("")`, a valid quoted url, and so + * does a run that cooks to pure whitespace immediately followed by a real quote, such as + * `url(\x20"a")`. Read as plain value text instead (the general branch below), whose + * spaces-before-the-run padding never manufactures a second token the way url content would. + */ + const opensQuotedArgument = + rawInsideUrl && + isCssQuoteCharacter(firstNonWhitespaceCharacter(cooked) ?? text[runEnd]) && + isFreshUrlArgument(text, runStart, readBack) + const insideUrl = rawInsideUrl && !opensQuotedArgument + /** + * A hex escape already in the cooked text is joined only when hex digits follow the run or, + * inside a url() argument, where padding after it would split it from its whitespace. + */ + const joined = + escapeStart === -1 + ? undefined + : escapesNext + ? joinEscapedCharacter(text, runEnd, cooked, padding, insideUrl) + : isHexDigit(text.charCodeAt(runEnd)) || insideUrl + ? joinOpenEscape(text, { cooked, end: runEnd, escapeStart, insideUrl, padding }) + : undefined + + if (joined) { + if (insideUrl) { + carryUrlState(text, runStart, joined.standIn, readBack) + } + writeStandIn(runStart, joined.end, joined.standIn) + copiedUntil = joined.end + } else if (insideUrl) { + const head = escapesNext ? cooked.slice(0, -1) : cooked + const { body, closesArgument, tail } = splitUrlStandIn(toStandIn(head)) + const fill = URL_PADDING.repeat(padding) + const backslash = escapesNext ? '\\' : '' + /** + * A run that cooks to whitespace alone has no content of its own for the fill to join. Right + * after a hex escape a previous run left open, the escape takes the first whitespace, so the + * fill follows it as more of the same token. At the argument's start, the whitespace is + * leading trivia, so the fill is spaces too. + */ + const isWhitespaceOnly = body.length === 0 && !closesArgument + const escapeOpenHere = + isWhitespaceOnly && openEscapeAt(text, runStart, readBack) !== undefined + const isLeadingTrivia = + isWhitespaceOnly && !escapeOpenHere && isFreshUrlArgument(text, runStart, readBack) + const standIn = escapeOpenHere + ? `${tail}${fill}${backslash}` + : isLeadingTrivia + ? `${' '.repeat(tail.length + padding)}${backslash}` + : `${body}${fill}${tail}${backslash}` + carryUrlState(text, runStart, standIn, readBack) + writeStandIn(runStart, runEnd, standIn) + copiedUntil = runEnd + } else { + const standIn = toStandIn(cooked) + /** + * Only a stand-in that starts with a name character or a CSS escape continues the name before + * it. Before any other character (`colr\x3a red`), the spaces stay between the name and the + * run, so an edit to the name alone (a rename quick fix) does not touch the run. A stand-in + * that is exactly "(" right after a name continues it too, since url() takes no whitespace + * before its "(" (`url\x28a)`); a run that cooks past the "(" keeps its spaces in place, since + * what follows the "(" is the argument, not more of the name. + */ + const opensCallAfterName = standIn === '(' && isNameCharacter(text[runStart - 1]) + const continuesName = isNameCharacter(standIn[0]) || standIn[0] === '\\' || opensCallAfterName + const nameStart = continuesName + ? findNameStart(text, runStart, copiedUntil, openName?.escapesNext === true) + : runStart + const nameEscapesNext = escapesNext && isEscapableCharacter(text[runEnd]) + const continuesOpenName = + openName !== undefined && + nameStart === copiedUntil && + (openName.escapesNext || isNameCharacter(openName.lastCharacter)) + + if (openName && continuesOpenName) { + openName.body += text.slice(copiedUntil, runStart) + standIn + openName.end = runEnd + openName.escapesNext = nameEscapesNext + openName.lastCharacter = standIn[standIn.length - 1] + openName.padding += padding + } else { + const before = + nameStart > copiedUntil + ? text[nameStart - 1] + : openName + ? openName.lastCharacter + : lastStandInCharacter + const joinsName = nameStart < runStart + const paddingMovesInFront = joinsName && isNameBoundary(before) + const hexStandIn = + joinsName && !paddingMovesInFront + ? hexEscapeLastCharacter(text, runEnd, cooked, padding, nameStart, runStart) + : undefined + if (hexStandIn !== undefined) { + writeStandIn(runStart, runEnd, hexStandIn) + } else { + const start = paddingMovesInFront ? nameStart : runStart + closeName() + result += text.slice(copiedUntil, start) + openName = { + body: text.slice(start, runStart) + standIn, + end: runEnd, + escapesNext: nameEscapesNext, + lastCharacter: standIn[standIn.length - 1], + padding, + start, + } + } + } + copiedUntil = runEnd + } + /** + * Outside a url() argument nothing is carried: the next read-back then answers no at the floor + * whatever escape is open there. + */ + if (!insideUrl) { + readBack.insideUrl = false + readBack.openEscape = undefined + } + runStart = text.indexOf('\\', copiedUntil) + } + closeName() + return result + text.slice(copiedUntil) +} + +/** + * The start of the name the escape run at `runStart` continues: reading back from it over name + * characters, not past `limit` (where the text already written ends), then over one leading + * character that belongs to the name's token (`#`, `.`, `@`, `:`, `!`), so the padding in front of + * a class, id, at-keyword, pseudo-class, or `!important` does not split it from its name. The raw + * character at `limit` counts as a name character when `limitIsEscaped` (a cooked backslash right + * before it escapes it). `runStart` when no name comes before the run. + */ +function findNameStart( + text: string, + runStart: number, + limit: number, + limitIsEscaped: boolean, +): number { + let start = runStart + while ( + start > limit && + (isNameCodeUnit(text.charCodeAt(start - 1)) || (limitIsEscaped && start - 1 === limit)) + ) { + start-- + } + if (start > limit && NAME_LEADERS.has(text[start - 1] ?? '')) { + start-- + } + return start +} + +/** Characters that open a token whose name follows directly: an id or hash, a class, an at-keyword, a pseudo-class, `!important`. */ +const NAME_LEADERS: ReadonlySet = new Set(['#', '.', '@', ':', '!']) + +/** + * True when whitespace may sit right after `character` (the one before a name, undefined at the + * start of the text) without changing what the CSS around it means: whitespace, the start of the + * text, or `;`, `{`, `}`, `(`, `,`. A name after any other character (`&.a`, `a:hover`) is joined + * to it, so moving the padding in front of the name would split that token instead. + */ +function isNameBoundary(character: string | undefined): boolean { + return ( + character === undefined || + isCssWhitespace(character) || + character === ';' || + character === '{' || + character === '}' || + character === '(' || + character === ',' + ) +} + +/** + * The stand-in for an escape run inside a name whose padding cannot move in front of the name: + * the cooked text with its last character written as a CSS hex escape that fills the padding + * (`co\x6Cr` after `&.` reads as `co\06cr`), followed directly by the raw text when the character + * there cannot continue or end a hex escape, or else ended by the one space a hex escape absorbs + * (`\6c ` before a hex digit). Undefined when that character is not a name character, when the + * name is a number or hash (`1\x30`, `#f\x66f`, where an escaped digit reads differently), when + * the cooked text ends with a CSS escape, or when the code does not fit in six hex digits. + */ +function hexEscapeLastCharacter( + text: string, + runEnd: number, + cooked: string, + padding: number, + nameStart: number, + runStart: number, +): string | undefined { + const isPair = + isLowSurrogate(cooked.charCodeAt(cooked.length - 1)) && + isHighSurrogate(cooked.charCodeAt(cooked.length - 2)) + const unitCount = isPair ? 2 : 1 + const codePoint = cooked.codePointAt(cooked.length - unitCount) + const head = cooked.slice(0, cooked.length - unitCount) + const leader = text[nameStart] ?? '' + const nameFirstIndex = NAME_LEADERS.has(leader) ? nameStart + 1 : nameStart + /** The identifier's first two characters, read across the raw name and the cooked text. */ + const identifierAt = (offset: number) => + nameFirstIndex + offset < runStart + ? text[nameFirstIndex + offset] + : cooked[nameFirstIndex + offset - runStart] + const startsNumber = + isDecimalDigit(identifierAt(0)) || (identifierAt(0) === '-' && isDecimalDigit(identifierAt(1))) + if ( + codePoint === undefined || + !isNameCodeUnit(codePoint) || + leader === '#' || + startsNumber || + endsWithEscapingBackslash(head) + ) { + return undefined + } + + const code = codePoint.toString(16) + const after = text[runEnd] + const endsBySpace = + after === undefined || + after === '\\' || + isHexDigit(after.charCodeAt(0)) || + isCssWhitespace(after) + const digits = padding + unitCount - (endsBySpace ? 2 : 1) + if (code.length > digits || digits > MAX_CSS_HEX_DIGITS) { + return undefined + } + return `${toStandIn(head)}\\${code.padStart(digits, '0')}${endsBySpace ? ' ' : ''}` +} + +/** True when `cooked` ends with an odd number of backslashes, so its last one escapes what follows. */ +function endsWithEscapingBackslash(cooked: string): boolean { + return countBackslashesBefore(cooked, cooked.length) % 2 === 1 +} + +interface JoinedStandIn { + /** Where the raw text resumes. */ + readonly end: number + readonly standIn: string +} + +/** + * The index of the backslash of the CSS escape `text` ends inside: a backslash with an odd count of + * backslashes up to it, then at most six hex digits to the end. -1 when `text` ends inside none. + * Reads only the last characters, so for most cooked text it answers from the last one. + */ +function openEscapeStart(text: string, end = text.length): number { + let index = end + const limit = Math.max(0, index - MAX_CSS_HEX_DIGITS) + while (index > limit && isHexDigit(text.charCodeAt(index - 1))) { + index-- + } + return text[index - 1] === '\\' && countBackslashesBefore(text, index) % 2 === 1 ? index - 1 : -1 +} + +interface OpenEscapeRun { + readonly cooked: string + readonly end: number + /** openEscapeStart of `cooked`. */ + readonly escapeStart: number + readonly insideUrl: boolean + readonly padding: number +} + +/** + * The stand-in for an escape run whose cooked text leaves a CSS hex escape open (its backslash in + * the cooked text, then fewer than six hex digits, or none), when hex digits continue that escape, + * with the padding spent where it cannot split the escape's digits from each other or from the + * whitespace the escape takes after them. The escape reads on over the raw hex digits after the run + * and over any later escape run whose cooked text starts with more of them; such a run joins the + * stand-in, cooked text and padding included, and the stand-in then covers through it. The padding + * becomes leading zeros right after the backslash, as long as the zeros and digits stay within the + * six a CSS hex escape reads; otherwise, inside an unquoted url() argument, URL_PADDING before the + * backslash. Undefined when the escape reads no hex digit, when neither placement applies, or when + * a hex escape already in the cooked text is outside a url() and no later run joins it, where the + * other stand-ins keep their meaning. + */ +function joinOpenEscape(text: string, run: OpenEscapeRun): JoinedStandIn | undefined { + const openEscape = run.cooked.length - 1 - run.escapeStart + const rawDigitsEnd = hexDigitsEnd(text, run.end, MAX_CSS_HEX_DIGITS - openEscape) + let read = openEscape + rawDigitsEnd - run.end + let end = run.end + let padding = run.padding + let joinedCooked = '' + const later = + read < MAX_CSS_HEX_DIGITS && text[rawDigitsEnd] === '\\' + ? readJoinedRuns(text, rawDigitsEnd, read) + : undefined + if (later) { + joinedCooked = text.slice(run.end, rawDigitsEnd) + later.cooked + end = later.end + padding += later.padding + read = later.read + } + if (read === 0 || (openEscape > 0 && !run.insideUrl && !later)) { + return undefined + } + + const before = toStandIn(run.cooked.slice(0, run.escapeStart)) + const escape = toStandIn(run.cooked.slice(run.escapeStart + 1) + joinedCooked) + if (read + padding <= MAX_CSS_HEX_DIGITS) { + return { end, standIn: `${before}\\${'0'.repeat(padding)}${escape}` } + } + return run.insideUrl + ? { end, standIn: `${before}${URL_PADDING.repeat(padding)}\\${escape}` } + : undefined +} + +/** + * The escape runs from `start` on that continue a CSS hex escape which has read `read` hex digits: + * each run whose cooked text starts with more of them, with the raw hex digits between. The last + * joined run's cooked text may go on past its digits. `read` counts the raw digits after the last + * joined run too, which stay outside it. Undefined when the run at `start` joins nothing. + */ +function readJoinedRuns( + text: string, + start: number, + read: number, +): { cooked: string; end: number; padding: number; read: number } | undefined { + let cooked = '' + let joinedEnd = start + let end = start + let padding = 0 + let count = read + while (count < MAX_CSS_HEX_DIGITS && text[end] === '\\') { + const next = readRun(text, end) + const leadingDigits = next ? hexDigitsEnd(next.cooked, 0, MAX_CSS_HEX_DIGITS - count) : 0 + if (!next || leadingDigits === 0) { + break + } + count += leadingDigits + padding += next.end - end - next.cooked.length + cooked += text.slice(joinedEnd, end) + next.cooked + joinedEnd = next.end + if (leadingDigits < next.cooked.length) { + break + } + end = hexDigitsEnd(text, joinedEnd, MAX_CSS_HEX_DIGITS - count) + count += end - joinedEnd + } + return joinedEnd === start ? undefined : { cooked, end: joinedEnd, padding, read: count } +} + +/** + * The stand-in for an escape run whose `cooked` text ends with a backslash, which at runtime + * escapes the character at `runEnd`, with the run's `padding` spent inside that CSS escape rather + * than as spaces that would split the name or url() around it. Before hex digits, the padding + * becomes leading zeros, as long as the digits and zeros stay within the six a CSS hex escape + * reads; when a backslash follows those digits, a later escape run may continue them, which + * joinOpenEscape handles, and when that join does not fit, the zeros cover this run alone. Before + * any other character, that character becomes its own hex code + * padded to fill the room, as long as the code fits and what follows it neither continues the hex + * digits nor is whitespace a hex escape would absorb (the end of the text and another escape count + * as unsafe). Undefined when none applies. + */ +function joinEscapedCharacter( + text: string, + runEnd: number, + cooked: string, + padding: number, + insideUrl: boolean, +): JoinedStandIn | undefined { + const head = cooked.slice(0, -1) + const hexDigitCount = hexDigitsEnd(text, runEnd, MAX_CSS_HEX_DIGITS) - runEnd + const joined = + hexDigitCount > 0 && text[runEnd + hexDigitCount] === '\\' + ? joinOpenEscape(text, { + cooked, + end: runEnd, + escapeStart: cooked.length - 1, + insideUrl, + padding, + }) + : undefined + if (joined) { + return joined + } + if (hexDigitCount > 0) { + return hexDigitCount + padding <= MAX_CSS_HEX_DIGITS + ? { end: runEnd, standIn: `${toStandIn(head)}\\${'0'.repeat(padding)}` } + : undefined + } + + const escaped = text.charCodeAt(runEnd) + const after = text[runEnd + 1] + const code = escaped.toString(16) + const room = padding + 1 + const isSafe = + !isHighSurrogate(escaped) && + !isLowSurrogate(escaped) && + !isCssWhitespace(text[runEnd]) && + code.length <= room && + after !== undefined && + after !== '\\' && + !isHexDigit(after.charCodeAt(0)) && + !isCssWhitespace(after) + return isSafe + ? { end: runEnd + 1, standIn: `${toStandIn(head)}\\${code.padStart(room, '0')}` } + : undefined +} + +/** + * The padding of an escape run inside an unquoted url() argument: a character a url token holds, + * where a space would end the token. + */ +const URL_PADDING = '_' + +/** + * Splits a stand-in written inside an unquoted url() argument where URL_PADDING goes: before the + * first ")" that closes the argument, or before the end, and there before any CSS whitespace that + * ends the url token. Filler after that whitespace would read as a second, invalid token, and filler + * after the ")" would land outside the argument, splitting whatever follows it. A ")" a backslash + * escapes, or whitespace a CSS escape takes (after a backslash, or after a hex escape's digits), is + * url content and stays in `body`. `closesArgument` is true when `tail` holds that ")". + */ +function splitUrlStandIn(standIn: string): { + body: string + closesArgument: boolean + tail: string +} { + const closeIndex = indexOfUnescapedCloseParenthesis(standIn) + let end = closeIndex === -1 ? standIn.length : closeIndex + while (end > 0 && isCssWhitespace(standIn[end - 1]) && openEscapeStart(standIn, end - 1) === -1) { + end-- + } + return { + body: standIn.slice(0, end), + closesArgument: closeIndex !== -1, + tail: standIn.slice(end), + } +} + +/** The index of the first ")" in `text` that no backslash right before it escapes, or -1 when none. */ +function indexOfUnescapedCloseParenthesis(text: string): number { + let index = text.indexOf(')') + while (index !== -1 && countBackslashesBefore(text, index) % 2 === 1) { + index = text.indexOf(')', index + 1) + } + return index +} + +/** + * A CSS escape still open at a position of the text CSS reads: the number of hex digits it has + * read after its backslash (0 right after the backslash), or undefined when no escape is open. + */ +type OpenEscape = number | undefined + +/** What the previous escape run leaves at the raw text right after it, where the next read-back stops. */ +interface UrlReadBack { + /** Where the previous run ended (`copiedUntil`), 0 before the first run. */ + floor: number + /** The raw text at `floor` is inside an unquoted url() argument. */ + insideUrl: boolean + /** The CSS escape the previous run's stand-in leaves open at `floor`, carried only after a run inside a url() argument. */ + openEscape: OpenEscape +} + +/** + * True when the escape run starting at `runStart` sits inside an unquoted url() or url-prefix() + * argument: reading back from it over url characters, with a character a CSS escape takes counted + * as url content, reaches the "(" of a function with that name (isUrlFunctionName), with only + * whitespace allowed right after that "(". A quote, a paren, or whitespace anywhere else, none of + * them taken by an escape, answers no. Reading stops at `readBack.floor`, where the previous run + * ended, and answers `readBack.insideUrl`, that run's own answer carried past its cooked text: runs + * are read left to right, so each stretch of text is read once, and a previous run's raw characters + * are never read as url content. + */ +function isInsideUnquotedUrl(text: string, runStart: number, readBack: UrlReadBack): boolean { + const { floor, openEscape } = readBack + for (let index = runStart - 1; index >= floor; index--) { + const character = text[index] + const isQuote = isCssQuoteCharacter(character) + const isWhitespace = !isQuote && !isParen(character) && isCssWhitespace(character) + if (!isQuote && !isWhitespace && !isParen(character)) { + continue + } + /** Only a run inside a url() argument carries an open escape (UrlReadBack). */ + if (openEscape !== undefined && isTakenByEscape(text, index, readBack)) { + continue + } + if (isWhitespace) { + while (index >= floor && isCssWhitespace(text[index])) { + index-- + } + return ( + index >= floor && + text[index] === '(' && + (openEscape === undefined || !isTakenByEscape(text, index, readBack)) && + isUrlFunctionName(text, index) + ) + } + return character === '(' && isUrlFunctionName(text, index) + } + return readBack.insideUrl +} + +function isParen(character: string | undefined): boolean { + return character === '(' || character === ')' +} + +/** A quote character, the kind vscode-css-languageservice reads a CSS string from. */ +function isCssQuoteCharacter(character: string | undefined): boolean { + return character === '"' || character === "'" +} + +/** `text`'s first non-whitespace character, or undefined when `text` holds none (including empty). */ +function firstNonWhitespaceCharacter(text: string): string | undefined { + for (let index = 0; index < text.length; index++) { + if (!isCssWhitespace(text[index])) { + return text[index] + } + } + return undefined +} + +/** + * True when only whitespace, if anything, lies between `runStart` and the "(" of the still-open + * unquoted url() argument isInsideUnquotedUrl already found it inside: the run is the argument's + * first content, so a cooked quote there opens the argument as a real CSS string rather than + * continuing an unquoted token (docs/architecture.md, JavaScript escapes). A previous run's open CSS + * escape (readBack.openEscape) already wrote real content, so this answers no without scanning: only + * a run that wrote nothing but content characters keeps an escape open past its own cooked text. + */ +function isFreshUrlArgument(text: string, runStart: number, readBack: UrlReadBack): boolean { + if (readBack.openEscape !== undefined) { + return false + } + let index = runStart - 1 + while (index >= readBack.floor && isCssWhitespace(text[index])) { + index-- + } + return index >= readBack.floor && text[index] === '(' +} + +/** + * True when a CSS escape takes the raw character at `index` (a quote, paren, or whitespace): the + * escape `openEscape` left open at `floor`, continued by the raw hex digits between `floor` and + * `index`, either escapes that character directly (a backslash right before a character other than + * a line break) or is a hex escape of at most six digits, which takes one whitespace after them. A + * CRLF counts as one whitespace. Raw text past `floor` holds no escape of its own: every valid + * JavaScript escape there would have been part of a run. + */ +function isTakenByEscape(text: string, index: number, { floor, openEscape }: UrlReadBack): boolean { + if (openEscape === undefined) { + return false + } + const character = text[index] + const start = character === '\n' && text[index - 1] === '\r' ? index - 1 : index + const digitCount = start - floor + if (digitCount < 0 || hexDigitsEnd(text, floor, digitCount) !== start) { + return false + } + const readDigits = openEscape + digitCount + return readDigits === 0 + ? isEscapableCharacter(character) + : isCssWhitespace(character) && readDigits <= MAX_CSS_HEX_DIGITS +} + +/** + * The CSS escape open at `runStart`: the one `openEscape` left open at `floor`, when only hex digits + * it still reads lie between. + */ +function openEscapeAt( + text: string, + runStart: number, + { floor, openEscape }: UrlReadBack, +): OpenEscape { + if (openEscape === undefined) { + return undefined + } + const digitCount = runStart - floor + const readDigits = openEscape + digitCount + return readDigits <= MAX_CSS_HEX_DIGITS && hexDigitsEnd(text, floor, digitCount) === runStart + ? readDigits + : undefined +} + +/** + * Updates `readBack` past a run inside a url() argument from the stand-in written for it, read as + * written, so padding inside a CSS escape counts as its digits. Reads `readBack` as the previous + * run left it before overwriting it. + */ +function carryUrlState( + text: string, + runStart: number, + standIn: string, + readBack: UrlReadBack, +): void { + const scan = scanUrlStandIn(standIn, openEscapeAt(text, runStart, readBack)) + readBack.insideUrl = !scan.endsUrl + readBack.openEscape = scan.openEscape +} + +interface CookedUrlScan { + /** The argument or its token ends inside the text and no later character reopens it. */ + readonly endsUrl: boolean + /** The CSS escape left open after the text. */ + readonly openEscape: OpenEscape +} + +/** + * Reads a stand-in written inside an unquoted url() argument as url content, from the CSS escape + * `openEscape` open before it. CSS whitespace no escape takes ends the current token, and a later + * character reopens it: the stand-in of a run that starts the argument can open with whitespace + * before its first token. A quote or paren no escape takes ends the argument for good. Other + * characters (U+00A0, U+3000, U+FEFF, and the rest of the JavaScript `\s` class that CSS does not + * count as whitespace) are url content. + */ +function scanUrlStandIn(standIn: string, openEscape: OpenEscape): CookedUrlScan { + let open = openEscape + let isOpen = open !== undefined + for (let index = 0; index < standIn.length; index++) { + const character = standIn[index] + if (open !== undefined) { + if (open < MAX_CSS_HEX_DIGITS && isHexDigit(standIn.charCodeAt(index))) { + open++ + isOpen = true + continue + } + const readDigits = open + open = undefined + if (readDigits === 0) { + isOpen = isEscapableCharacter(character) + continue + } + if (isCssWhitespace(character)) { + index += character === '\r' && standIn[index + 1] === '\n' ? 1 : 0 + continue + } + } + if (isCssQuoteCharacter(character) || isParen(character)) { + return { endsUrl: true, openEscape: undefined } + } + if (character === '\\') { + open = 0 + } + isOpen = !isCssWhitespace(character) + } + return { endsUrl: !isOpen, openEscape: open } +} + +/** The number of backslashes in the text right before `index`. */ +function countBackslashesBefore(text: string, index: number): number { + let count = 0 + while (text[index - 1 - count] === '\\') { + count++ + } + return count +} + +/** + * True when the raw name right before the "(" at `parenIndex` is `url` or `url-prefix`, compared + * case-insensitively, and starts a token (no name character, "#", "@", or backslash before it). + * The name is read from the raw text, so a name written with an escape inside or directly before + * it is not `url`. + */ +function isUrlFunctionName(text: string, parenIndex: number): boolean { + let nameStart = parenIndex + while (nameStart > 0 && isNameCharacter(text[nameStart - 1])) { + nameStart-- + } + const before = text[nameStart - 1] + return ( + before !== '#' && + before !== '@' && + before !== '\\' && + namesUrlFunction(text.slice(nameStart, parenIndex)) + ) +} + +/** The escape run whose first backslash is at `start`: adjacent escapes, cooked as a whole. Undefined when the first escape is invalid. */ +function readRun(text: string, start: number): { cooked: string; end: number } | undefined { + let end = start + let cooked = '' + for (let escape = readEscape(text, end); escape; escape = readEscape(text, end)) { + cooked += escape.value + end = escape.end + } + return end === start ? undefined : { cooked, end } +} + +interface Escape { + readonly end: number + readonly value: string +} + +/** + * Reads the template escape sequence whose backslash is at `index`, following the ECMAScript + * TemplateCharacter grammar: a line continuation (cooked to nothing), a single-character escape, + * `\0` not followed by a digit, `\x` with two hex digits, `\u` with four hex digits or a braced code + * point, or any other character standing for itself. Undefined when there is no backslash at + * `index` or the escape is invalid. + */ +function readEscape(text: string, index: number): Escape | undefined { + if (text[index] !== '\\') { + return undefined + } + const next = text[index + 1] + if (next === undefined) { + return undefined + } + if (next === '\r') { + return { end: text[index + 2] === '\n' ? index + 3 : index + 2, value: '' } + } + if (next === '\n' || next === LINE_SEPARATOR || next === PARAGRAPH_SEPARATOR) { + return { end: index + 2, value: '' } + } + const singleEscape = SINGLE_ESCAPES.get(next) + if (singleEscape !== undefined) { + return { end: index + 2, value: singleEscape } + } + if (next === '0') { + return isDecimalDigit(text[index + 2]) ? undefined : { end: index + 2, value: '\0' } + } + if (isDecimalDigit(next)) { + return undefined + } + if (next === 'x') { + return readHexEscape(text, index + 2, 2) + } + if (next === 'u') { + return text[index + 2] === '{' + ? readCodePointEscape(text, index + 3) + : readHexEscape(text, index + 2, 4) + } + const isSurrogatePair = + isHighSurrogate(next.charCodeAt(0)) && isLowSurrogate(text.charCodeAt(index + 2)) + const end = isSurrogatePair ? index + 3 : index + 2 + return { end, value: text.slice(index + 1, end) } +} + +/** Exactly `digitCount` hex digits starting at `start`, as one UTF-16 code unit. */ +function readHexEscape(text: string, start: number, digitCount: number): Escape | undefined { + const end = hexDigitsEnd(text, start, digitCount) + return end === start + digitCount + ? { end, value: String.fromCharCode(parseInt(text.slice(start, end), 16)) } + : undefined +} + +/** One or more hex digits starting at `start` and a closing "}", naming a code point up to U+10FFFF. */ +function readCodePointEscape(text: string, start: number): Escape | undefined { + const index = hexDigitsEnd(text, start, Number.POSITIVE_INFINITY) + if (index === start || text[index] !== '}') { + return undefined + } + const codePoint = parseInt(text.slice(start, index), 16) + return codePoint > MAX_CODE_POINT + ? undefined + : { end: index + 1, value: String.fromCodePoint(codePoint) } +} + +/** A control character (below U+0020, and U+007F) or a Unicode line or paragraph separator. */ +const BLANK_PATTERN = new RegExp( + `[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}${LINE_SEPARATOR}${PARAGRAPH_SEPARATOR}]`, + 'g', +) + +/** + * The cooked text with every control character and Unicode line or paragraph separator as a space. + * Most cooked runs are one or two characters with no blank, which a code-unit scan answers without + * running the expression (measured faster than an unconditional replace for runs that short). + */ +function toStandIn(cooked: string): string { + for (let index = 0; index < cooked.length; index++) { + if (isBlankCode(cooked.charCodeAt(index))) { + return cooked.replace(BLANK_PATTERN, ' ') + } + } + return cooked +} + +/** A code unit BLANK_PATTERN matches. */ +function isBlankCode(code: number): boolean { + return ( + code < 0x20 || + code === 0x7f || + code === LINE_SEPARATOR_CODE || + code === PARAGRAPH_SEPARATOR_CODE + ) +} + +/** The characters after a backslash that stand for a different character: `\b`, `\f`, `\n`, `\r`, `\t`, `\v`. */ +const SINGLE_ESCAPES: ReadonlyMap = new Map([ + ['b', '\b'], + ['f', '\f'], + ['n', '\n'], + ['r', '\r'], + ['t', '\t'], + ['v', '\v'], +]) + +const MAX_CODE_POINT = 0x10ffff + +function isDecimalDigit(character: string | undefined): boolean { + return character !== undefined && character >= '0' && character <= '9' +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff +} diff --git a/src/virtual-document/styled-virtual-document-provider.ts b/src/virtual-document/styled-virtual-document-provider.ts new file mode 100644 index 0000000..587e69d --- /dev/null +++ b/src/virtual-document/styled-virtual-document-provider.ts @@ -0,0 +1,544 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' +import { TextDocument } from 'vscode-languageserver-textdocument' + +import { + CSS_TAG_NAME, + GLOBAL_STYLE_TAG_NAMES, + KEYFRAMES_TAG_NAME, +} from '../configuration/plugin-configuration.ts' +import { + commentEnd, + createCssCodeScanState, + createScanCandidatePattern, + identifierEnd, + isCssWhitespace, + isLineTerminator, + isNameCharacter, + isStatementBoundary, + isWhitespace, + LINE_SEPARATOR, + nonCodeEnd, + PARAGRAPH_SEPARATOR, + startsCodeEscape, +} from './css-code-scanner.ts' +import { getTemplateCssText } from './javascript-escapes.ts' +import { + createTemplateLineMap, + virtualOffsetToPosition, + virtualPositionToOffset, +} from './template-line-map.ts' + +export interface VirtualDocumentProvider { + createVirtualDocument(context: TemplateContext): TextDocument + canReuseVirtualDocument?(previousContext: TemplateContext, context: TemplateContext): boolean + toVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter + fromVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter + toVirtualDocOffset(offset: number, context: TemplateContext): number + fromVirtualDocOffset(offset: number, context: TemplateContext): number + getVirtualDocumentWrapper(context: TemplateContext): string +} + +const ROOT_WRAPPER = ':root{\n' +const KEYFRAMES_WRAPPER = '@keyframes custom {\n' +const VALUE_WRAPPER = ':root{all:\n' + +export class StyledVirtualDocumentProvider implements VirtualDocumentProvider { + /** + * getVirtualDocumentWrapper runs on every offset translation, and the value-wrapper choice scans + * the substituted text, so each context's reading is kept here, and so is the last one computed: + * the decorator builds a fresh context per request, and a request on unchanged text reuses it. + */ + private readonly readingByContext = new WeakMap() + private lastReading?: { + readonly rawText: string + readonly reading: TemplateReading + readonly tagKind: TagKind + } + + public constructor(private readonly typescript: typeof ts) {} + + /** + * The virtual SCSS document for a template (docs/architecture.md, "Virtual document"). Its + * positionAt/offsetAt read only the template's line map, never the TemplateContext that built + * it, so a reused document keeps mapping correctly after an edit elsewhere in the file moves the + * template (docs/architecture.md, "Caching"). + */ + public createVirtualDocument(context: TemplateContext): TextDocument { + const reading = this.getReading(context) + return buildVirtualDocument(context, reading.wrapper, reading.topLevelIsNested) + } + + /** + * For a `css` template whose text is one identifier, which reads as a value as much as a property + * name, the same document under the value wrapper, so validation can keep only what both readings + * report (docs/architecture.md, Virtual document). Undefined for any other template. + */ + public createValueReadingDocument(context: TemplateContext): TextDocument | undefined { + const reading = this.getReading(context) + return reading.isSingleIdentifier + ? buildVirtualDocument(context, VALUE_WRAPPER, reading.topLevelIsNested) + : undefined + } + + /** + * A cached document's positionAt/offsetAt close over the line map of the *previous* context's + * raw text, and its text depends on the reading, so reuse requires the same raw text (two + * placeholders of equal length can substitute to identical context.text while having different + * line maps) and the same tag kind, of which with the raw text the reading is a pure function. + */ + public canReuseVirtualDocument( + previousContext: TemplateContext, + context: TemplateContext, + ): boolean { + return ( + previousContext.fileName === context.fileName && + previousContext.rawText === context.rawText && + this.getReadingKey(previousContext) === this.getReadingKey(context) + ) + } + + public toVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter { + return { line: position.line + 1, character: position.character } + } + + public fromVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter { + return { line: position.line - 1, character: position.character } + } + + public toVirtualDocOffset(offset: number, context: TemplateContext): number { + return offset + this.getVirtualDocumentWrapper(context).length + } + + public fromVirtualDocOffset(offset: number, context: TemplateContext): number { + return offset - this.getVirtualDocumentWrapper(context).length + } + + public getVirtualDocumentWrapper(context: TemplateContext): string { + return this.getReading(context).wrapper + } + + /** + * The template's tag kind as a string: everything besides the raw text that its reading, and so + * its document and diagnostics, depends on. Two contexts with the same raw text and the same key + * validate alike. Reads only the tag, so a cache key costs no substitution. + */ + public getReadingKey(context: TemplateContext): string { + return this.getTagKind(context) + } + + private getReading(context: TemplateContext): TemplateReading { + const cached = this.readingByContext.get(context) + if (cached !== undefined) { + return cached + } + const tagKind = this.getTagKind(context) + const { lastReading } = this + const reading = + lastReading !== undefined && + lastReading.tagKind === tagKind && + lastReading.rawText === context.rawText + ? lastReading.reading + : readTemplate(context, tagKind) + this.lastReading = { rawText: flattenText(context.rawText), reading, tagKind } + this.readingByContext.set(context, reading) + return reading + } + + private getTagKind(context: TemplateContext): TagKind { + const parent = context.node.parent + const tag = + parent && this.typescript.isTaggedTemplateExpression(parent) ? parent.tag : undefined + const tagName = getTagName(this.typescript, tag) + if (tagName === CSS_TAG_NAME) { + return TagKind.Css + } + if (tagName === KEYFRAMES_TAG_NAME) { + return TagKind.Keyframes + } + return tagName !== undefined && GLOBAL_STYLE_TAG_NAMES.includes(tagName) + ? TagKind.GlobalStyle + : TagKind.Other + } +} + +/** + * A copy of `text` that references none of the open file's text: `text` is `context.rawText`, a + * slice of the whole file, and V8 represents a slice of a long string as a view into its parent. + * Slicing the leading character back off a concatenation makes V8 flatten that concatenation into a + * new string first, so the result is at most a view into that fresh copy, one character longer than + * `text`, never into the file (mirrors flattenCacheKey in src/features/diagnostics.ts). + */ +function flattenText(text: string): string { + return (' ' + text).slice(1) +} + +/** The tag names that read a template differently from any other tag. */ +const TagKind = { + Css: 'css', + GlobalStyle: 'global-style', + Keyframes: 'keyframes', + Other: 'other', +} as const + +type TagKind = (typeof TagKind)[keyof typeof TagKind] + +/** Everything besides the raw text that shapes a template's document and its diagnostics. */ +interface TemplateReading { + /** A `css` template whose text is one identifier: a value and a property name read it alike. */ + readonly isSingleIdentifier: boolean + /** A block `@layer` at the template's top level sits in a nested position: every tag but a global-style tag. */ + readonly topLevelIsNested: boolean + readonly wrapper: string +} + +/** Only a `css` template's reading reads its text, the substituted text with escapes replaced. */ +function readTemplate(context: TemplateContext, tagKind: TagKind): TemplateReading { + const shape = + tagKind === TagKind.Css ? getCssFragmentShape(getTemplateCssText(context)) : undefined + const wrapper = + tagKind === TagKind.Keyframes + ? KEYFRAMES_WRAPPER + : shape === CssFragmentShape.Value + ? VALUE_WRAPPER + : ROOT_WRAPPER + return { + isSingleIdentifier: shape === CssFragmentShape.SingleIdentifier, + topLevelIsNested: tagKind !== TagKind.GlobalStyle, + wrapper, + } +} + +function buildVirtualDocument( + context: TemplateContext, + wrapper: string, + topLevelIsNested: boolean, +): TextDocument { + const contents = `${wrapper}${normalizeVirtualText(getTemplateCssText(context), topLevelIsNested)}\n}` + const lineMap = createTemplateLineMap(context) + + const positionAt = (offset: number): ts.LineAndCharacter => + virtualOffsetToPosition(offset, wrapper, lineMap) + const offsetAt = (position: ts.LineAndCharacter): number => + virtualPositionToOffset(position, wrapper, lineMap) + + /** + * getLineRange/getEOLCharacters/lineCount are not called by vscode-css-languageservice or + * by this plugin; a plain document over the literal contents satisfies the TextDocument + * shape for them without needing template-aware translation. + */ + const lineDocument = TextDocument.create('untitled://embedded.scss', 'scss', 1, contents) + + return { + uri: lineDocument.uri, + languageId: lineDocument.languageId, + version: lineDocument.version, + getText: (range) => + range ? contents.slice(offsetAt(range.start), offsetAt(range.end)) : contents, + getLineRange: (line) => lineDocument.getLineRange(line), + getEOLCharacters: (line) => lineDocument.getEOLCharacters(line), + get lineCount() { + return lineDocument.lineCount + }, + positionAt, + offsetAt, + } +} + +const SEPARATOR_PATTERN = new RegExp(`[${LINE_SEPARATOR}${PARAGRAPH_SEPARATOR}]`, 'g') + +/** The positions normalizeVirtualText acts on: nonCodeEnd's own, an at-keyword, a brace, and a separator. */ +const NORMALIZE_CANDIDATE_PATTERN = createScanCandidatePattern( + `@{}${LINE_SEPARATOR}${PARAGRAPH_SEPARATOR}`, +) + +/** + * Separator normalization and the nested block `@layer` rewrite (docs/architecture.md, "Virtual + * document"), in one pass that jumps between the positions either can act on and copies each + * stretch between them unchanged. Skipped when the text holds neither a separator nor `@layer`. + */ +function normalizeVirtualText(text: string, topLevelIsNested: boolean): string { + const hasSeparator = text.includes(LINE_SEPARATOR) || text.includes(PARAGRAPH_SEPARATOR) + if (!hasSeparator && !hasLayerKeyword(text)) { + return text + } + + let result = '' + let copiedUntil = 0 + let depth = 0 + const state = createCssCodeScanState() + const candidates = NORMALIZE_CANDIDATE_PATTERN + let index = 0 + for (;;) { + candidates.lastIndex = index + const match = candidates.exec(text) + if (match === null) { + break + } + index = match.index + const character = text[index] + const end = nonCodeEnd(text, index, state) + if (end !== -1) { + if (hasSeparator && character !== '"' && character !== "'") { + result += + text.slice(copiedUntil, index) + text.slice(index, end).replace(SEPARATOR_PATTERN, '\n') + copiedUntil = end + } + index = end + continue + } + + if (!state.url && character === '@' && (depth > 0 || topLevelIsNested)) { + const bodyStart = findLayerBlockBodyStart(text, index) + if (bodyStart !== undefined) { + /** Outside whole comments the prelude holds no quote, "/", "(", or ")" (findLayerBlockBodyStart), so skipping it past nonCodeEnd is safe. */ + result += text.slice(copiedUntil, index) + rewriteLayerPrelude(text, index, bodyStart) + copiedUntil = index = bodyStart + continue + } + } + if (!state.url && character === '{') { + depth++ + } else if (!state.url && character === '}') { + depth-- + } else if (character === LINE_SEPARATOR || character === PARAGRAPH_SEPARATOR) { + result += `${text.slice(copiedUntil, index)}\n` + copiedUntil = index + 1 + } + index++ + } + return result + text.slice(copiedUntil) +} + +const CssFragmentShape = { + /** Declarations, rules, or nothing: the declaration wrapper. */ + Other: 'other', + /** One identifier, a value or a property name being typed: the declaration wrapper, for completions. */ + SingleIdentifier: 'single-identifier', + /** A property value: the value wrapper. */ + Value: 'value', +} as const + +type CssFragmentShape = (typeof CssFragmentShape)[keyof typeof CssFragmentShape] + +/** The positions getCssFragmentShape acts on: nonCodeEnd's own and the structural characters. */ +const FRAGMENT_CANDIDATE_PATTERN = createScanCandidatePattern(';{}:()') + +/** + * How a `css` template's substituted text reads (docs/architecture.md, Virtual document, for the + * rule): as a single property value, as one identifier, or as declarations or rules. A string's + * opening quote and a whole escaped run count as significant text, a comment does not. Jumps + * between the positions that can change the answer, and stops at the first structural character, + * so an ordinary declaration fragment costs only the scan up to its first ":". + */ +function getCssFragmentShape(text: string): CssFragmentShape { + let parenthesisDepth = 0 + let firstSignificant = -1 + let lastSignificant = -1 + /** Records the first and last non-whitespace characters of [from, to), all of it plain code. */ + const readPlainCode = (from: number, to: number) => { + let last = to - 1 + while (last >= from && isWhitespace(text[last])) { + last-- + } + if (last < from) { + return + } + lastSignificant = last + if (firstSignificant === -1) { + let first = from + while (isWhitespace(text[first])) { + first++ + } + firstSignificant = first + } + } + const state = createCssCodeScanState() + const candidates = FRAGMENT_CANDIDATE_PATTERN + let index = 0 + while (index < text.length) { + candidates.lastIndex = index + const candidate = candidates.exec(text)?.index ?? text.length + readPlainCode(index, candidate) + if (candidate === text.length) { + break + } + index = candidate + const character = text[index] + const end = nonCodeEnd(text, index, state) + if (end !== -1) { + if (character === '\\' || character === '"' || character === "'") { + firstSignificant = firstSignificant === -1 ? index : firstSignificant + lastSignificant = character === '\\' ? end - 1 : index + } + index = end + continue + } + if (!state.url && isStatementBoundary(character)) { + return CssFragmentShape.Other + } + if (character === ':' && parenthesisDepth === 0) { + return CssFragmentShape.Other + } + if (character === '(') { + parenthesisDepth++ + } else if (character === ')') { + parenthesisDepth = Math.max(parenthesisDepth - 1, 0) + } + readPlainCode(index, index + 1) + index++ + } + if (firstSignificant === -1 || startsSelectorOrAtRule(text, firstSignificant)) { + return CssFragmentShape.Other + } + return isSingleIdentifier(text, firstSignificant, lastSignificant + 1) + ? CssFragmentShape.SingleIdentifier + : CssFragmentShape.Value +} + +/** + * True when [start, end) is one CSS identifier and nothing else (up to two leading "-", then a + * name-start character or an escape, then name characters and escapes, as the CSS scanner joins + * them), the shape of a property name mid-typing (`dis`) as much as of a keyword value (`green`). + */ +function isSingleIdentifier(text: string, start: number, end: number): boolean { + let index = start + while (index < end && index - start < 2 && text[index] === '-') { + index++ + } + const nameStart = text.charCodeAt(index) + const isNameStart = + (nameStart >= 0x61 && nameStart <= 0x7a) || + (nameStart >= 0x41 && nameStart <= 0x5a) || + nameStart === 0x5f || + nameStart > 0x7f || + (text[index] === '\\' && startsCodeEscape(text, index)) + if (index >= end || !isNameStart) { + return false + } + return identifierEnd(text, index) >= end +} + +function startsSelectorOrAtRule(text: string, index: number): boolean { + const character = text[index] + if (character === '.') { + const next = text.charCodeAt(index + 1) + return !(next >= 0x30 && next <= 0x39) + } + return ( + character === '&' || + character === '>' || + character === '+' || + character === '~' || + character === '*' || + character === '[' || + character === '@' + ) +} + +/** The at-keyword, matched case-insensitively as the CSS parser matches keywords. Sticky, so it matches at `lastIndex` only. */ +const LAYER_KEYWORD_PATTERN = /@layer/iy +const LAYER_KEYWORD_LENGTH = '@layer'.length + +/** True when `@layer` appears anywhere in any letter case; "@" is rare in CSS, so this visits few places. */ +function hasLayerKeyword(text: string): boolean { + for (let index = text.indexOf('@'); index !== -1; index = text.indexOf('@', index + 1)) { + LAYER_KEYWORD_PATTERN.lastIndex = index + if (LAYER_KEYWORD_PATTERN.test(text)) { + return true + } + } + return false +} + +/** + * Returns the offset of the "{" that opens a block `@layer` whose keyword starts at `atOffset`, or + * undefined when the text there is not one: the keyword must be followed by whitespace, a comment, + * or "{", then at most one layer name (name characters and "." separators) surrounded by + * whitespace and comments, which separate tokens the way whitespace does. A list prelude, a + * statement form, an unterminated comment, or any other character (a string, an interpolation's + * filler that is not a name) leaves the prelude untouched. Scans at most the prelude itself. + */ +function findLayerBlockBodyStart(text: string, atOffset: number): number | undefined { + LAYER_KEYWORD_PATTERN.lastIndex = atOffset + if (!LAYER_KEYWORD_PATTERN.test(text)) { + return undefined + } + let index = atOffset + LAYER_KEYWORD_LENGTH + const afterKeyword = text[index] + if (afterKeyword !== '{' && afterKeyword !== '/' && !isCssWhitespace(afterKeyword)) { + return undefined + } + let sawName = false + let nameEnded = false + for (; index < text.length; index++) { + const character = text[index] + if (character === '{') { + return index + } + if (character === '/') { + const end = commentEnd(text, index) + if (end === -1) { + return undefined + } + nameEnded ||= sawName + index = end - 1 + continue + } + if (isCssWhitespace(character)) { + nameEnded ||= sawName + continue + } + if (nameEnded || !isLayerNameCharacter(character)) { + return undefined + } + sawName = true + } + return undefined +} + +/** + * Same-length replacement for a block `@layer` prelude spanning [start, end): every character + * becomes a space except line terminators (kept, with U+2028/U+2029 normalized to "\n" as + * everywhere else outside strings) and the last non-terminator character, which becomes "&", so + * `@layer utilities {` becomes spaces followed by `&{`. Hover over the keyword lands on + * whitespace, the same empty result the parser gives for a well-formed `@layer` keyword. + */ +function rewriteLayerPrelude(text: string, start: number, end: number): string { + let ampersandOffset = end - 1 + while (ampersandOffset > start && isLineTerminator(text[ampersandOffset])) { + ampersandOffset-- + } + let result = '' + for (let index = start; index < end; index++) { + const character = text[index] + if (index === ampersandOffset) { + result += '&' + } else if (isLineTerminator(character)) { + result += character === '\r' ? character : '\n' + } else { + result += ' ' + } + } + return result +} + +/** + * Name characters plus the "." that separates the segments of a dotted layer name such as + * `framework.base`. Placeholder filler ("x") qualifies, so `@layer ${name} {` is rewritten too. + */ +function isLayerNameCharacter(character: string): boolean { + return character === '.' || isNameCharacter(character) +} + +function getTagName(typescript: typeof ts, tag: ts.Expression | undefined): string | undefined { + if (tag && typescript.isIdentifier(tag)) { + return tag.text + } + if (tag && typescript.isPropertyAccessExpression(tag)) { + return tag.name.text + } + return undefined +} diff --git a/src/virtual-document/template-line-map.ts b/src/virtual-document/template-line-map.ts new file mode 100644 index 0000000..83fab35 --- /dev/null +++ b/src/virtual-document/template-line-map.ts @@ -0,0 +1,315 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' + +import { LINE_SEPARATOR_CODE, PARAGRAPH_SEPARATOR_CODE } from './css-code-scanner.ts' +import { type EscapeRun, getTemplateEscapeRuns } from './javascript-escapes.ts' +import type { VirtualDocumentProvider } from './styled-virtual-document-provider.ts' + +interface TemplateEnd { + readonly offset: number + readonly position: ts.LineAndCharacter +} + +/** + * A template's line-start table, template end, and escape runs, all pure functions of the raw + * template text: built once per document and threaded through every position a feature + * translates (docs/architecture.md, "Virtual document", the line map). + */ +export interface TemplateLineMap { + /** The template's JavaScript escape runs (getTemplateEscapeRuns), which mapped spans never split. */ + readonly escapeRuns: readonly EscapeRun[] + readonly lineStarts: readonly number[] + readonly templateEnd: TemplateEnd +} + +/** Template-relative offsets [start, end). */ +export interface TemplateSpan { + readonly end: number + readonly start: number +} + +interface TemplateRange { + readonly end: ts.LineAndCharacter + readonly start: ts.LineAndCharacter +} + +type VirtualPositionMapper = Pick + +const lineMapByContext = new WeakMap, TemplateLineMap>() + +const NO_ESCAPE_RUNS: readonly EscapeRun[] = [] + +/** + * The TemplateLineMap of context.rawText and the escape runs of context.text, kept per context so + * the document and every feature reading the same context share one line-start table. The escape + * runs are read on first use and only when the raw text holds a backslash, since they need the + * substituted text and a placeholder fill never holds a backslash. + */ +export function createTemplateLineMap( + context: Pick, +): TemplateLineMap { + const cached = lineMapByContext.get(context) + if (cached !== undefined) { + return cached + } + const lineStarts = computeLineStarts(context.rawText) + let escapeRuns: readonly EscapeRun[] | undefined + const lineMap: TemplateLineMap = { + /** + * Reads `context.text` of this closure's own context on first access, whichever later request + * (a reused line map's own context included) triggers it first: safe only because a caller + * that can reach a `TemplateLineMap` has always already read this same context's `.text` via + * `createVirtualDocument` first (docs/architecture.md, "JavaScript escapes", Escape runs). + */ + get escapeRuns() { + escapeRuns ??= context.rawText.includes('\\') + ? getTemplateEscapeRuns(context) + : NO_ESCAPE_RUNS + return escapeRuns + }, + lineStarts, + templateEnd: { + offset: context.rawText.length, + position: offsetToPosition(context.rawText.length, lineStarts), + }, + } + lineMapByContext.set(context, lineMap) + return lineMap +} + +/** + * The position of an offset into a virtual document whose text is `wrapper` followed by the + * template: an offset inside the wrapper's own (single) line resolves against it, and an offset + * inside the closing "\n}" clamps to the template end. + */ +export function virtualOffsetToPosition( + offset: number, + wrapper: string, + lineMap: TemplateLineMap, +): ts.LineAndCharacter { + if (offset < wrapper.length) { + return { line: 0, character: Math.max(offset, 0) } + } + const templateOffset = clamp(offset - wrapper.length, 0, lineMap.templateEnd.offset) + const { line, character } = offsetToPosition(templateOffset, lineMap.lineStarts) + return { line: line + 1, character } +} + +/** The inverse of virtualOffsetToPosition, clamping the same way. */ +export function virtualPositionToOffset( + position: ts.LineAndCharacter, + wrapper: string, + lineMap: TemplateLineMap, +): number { + if (position.line <= 0) { + return clamp(position.character, 0, wrapper.length - 1) + } + return ( + wrapper.length + + clampedTemplateOffset({ line: position.line - 1, character: position.character }, lineMap) + ) +} + +/** + * Resolves a virtual document position to a template-relative position for diagnostics, hover, + * and folding: a position past the template end (the closing "\n}") clamps to the template end, + * and one inside the opening wrapper is undefined. + */ +export function resolveTemplatePosition( + provider: VirtualPositionMapper, + position: ts.LineAndCharacter, + lineMap: TemplateLineMap, +): ts.LineAndCharacter | undefined { + const sourcePosition = provider.fromVirtualDocPosition(position) + if (sourcePosition.line < 0) { + return undefined + } + return isAfter(sourcePosition, lineMap.templateEnd.position) + ? lineMap.templateEnd.position + : sourcePosition +} + +/** + * Resolves a virtual document position to a template-relative position for code-fix edits, + * undefined for any position outside the template body. A position is outside exactly when it does + * not round-trip through the line-start table unchanged: its character is past the end of its line + * (spilling into where the next line's offsets live), or its line is past the template's last line. + */ +export function resolveTemplatePositionStrict( + provider: VirtualPositionMapper, + position: ts.LineAndCharacter, + lineMap: TemplateLineMap, +): ts.LineAndCharacter | undefined { + const sourcePosition = provider.fromVirtualDocPosition(position) + if (sourcePosition.line < 0) { + return undefined + } + const offset = positionToOffset(sourcePosition, lineMap.lineStarts) + return offset >= 0 && + offset <= lineMap.templateEnd.offset && + positionsEqual(sourcePosition, offsetToPosition(offset, lineMap.lineStarts)) + ? sourcePosition + : undefined +} + +/** + * Resolves a virtual document range to template offsets the way resolveTemplatePosition resolves + * each end, widened to whole escape runs (a diagnostic on `\x63olr` underlines all of it, not + * `3olr`). Used for diagnostics, hover, and folding. + */ +export function fromVirtualDocSpan( + provider: VirtualPositionMapper, + range: TemplateRange, + lineMap: TemplateLineMap, +): TemplateSpan | undefined { + const start = resolveTemplatePosition(provider, range.start, lineMap) + const end = resolveTemplatePosition(provider, range.end, lineMap) + if (!start || !end) { + return undefined + } + return widenToEscapeRuns( + positionToOffset(start, lineMap.lineStarts), + positionToOffset(end, lineMap.lineStarts), + lineMap.escapeRuns, + ) +} + +/** + * Resolves a code-fix edit range to template offsets the way resolveTemplatePositionStrict + * resolves each end, undefined when it overlaps an escape run (or, when empty, sits inside one): + * the edit's text is written for the stand-in, so applied to the escape as written it would + * corrupt it (`\x63olr` renamed to `color` edits `3olr` into `\x6color`). + */ +export function fromVirtualDocSpanStrict( + provider: VirtualPositionMapper, + range: TemplateRange, + lineMap: TemplateLineMap, +): TemplateSpan | undefined { + const startPosition = resolveTemplatePositionStrict(provider, range.start, lineMap) + const endPosition = resolveTemplatePositionStrict(provider, range.end, lineMap) + if (!startPosition || !endPosition) { + return undefined + } + const start = positionToOffset(startPosition, lineMap.lineStarts) + const end = positionToOffset(endPosition, lineMap.lineStarts) + const run = findFirstRunEndingAfter(start, lineMap.escapeRuns) + return run && run.start < end ? undefined : { end, start } +} + +/** Widens template offsets [start, end) so neither lies strictly inside an escape run. */ +export function widenToEscapeRuns( + start: number, + end: number, + escapeRuns: readonly EscapeRun[], +): TemplateSpan { + const startRun = findFirstRunEndingAfter(start, escapeRuns) + const endRun = findFirstRunEndingAfter(end, escapeRuns) + return { + end: endRun && endRun.start < end ? endRun.end : end, + start: startRun && startRun.start < start ? startRun.start : start, + } +} + +/** Binary search for the first run whose end is past `offset`, the only run that can hold it. */ +function findFirstRunEndingAfter( + offset: number, + escapeRuns: readonly EscapeRun[], +): EscapeRun | undefined { + let low = 0 + let high = escapeRuns.length + while (low < high) { + const mid = (low + high) >> 1 + if (escapeRuns[mid].end > offset) { + high = mid + } else { + low = mid + 1 + } + } + return escapeRuns[low] +} + +/** + * A template-relative offset as a template-relative position, clamped into the template, for a + * feature that maps toward the virtual document without TemplateContext.toPosition. + */ +export function templateOffsetToPosition( + offset: number, + lineMap: TemplateLineMap, +): ts.LineAndCharacter { + return offsetToPosition(clamp(offset, 0, lineMap.templateEnd.offset), lineMap.lineStarts) +} + +/** The offset of a template-relative position, clamped into [template start, template end]. */ +function clampedTemplateOffset(position: ts.LineAndCharacter, lineMap: TemplateLineMap): number { + const { lineStarts, templateEnd } = lineMap + if (isAfter(position, templateEnd.position)) { + return templateEnd.offset + } + return clamp(positionToOffset(position, lineStarts), 0, templateEnd.offset) +} + +/** + * Line starts matching TypeScript's own line-terminator rules (docs/tsserver-host.md): "\r\n", a + * lone "\n", a lone "\r", U+2028, and U+2029 each start a new line. Implemented here because the + * host's computeLineStarts is not part of its public, typed surface on every supported version. + */ +function computeLineStarts(text: string): number[] { + const lineStarts = [0] + let position = 0 + while (position < text.length) { + const charCode = text.charCodeAt(position) + position++ + if (charCode === CARRIAGE_RETURN_CODE) { + if (text.charCodeAt(position) === LINE_FEED_CODE) { + position++ + } + lineStarts.push(position) + } else if ( + charCode === LINE_FEED_CODE || + charCode === LINE_SEPARATOR_CODE || + charCode === PARAGRAPH_SEPARATOR_CODE + ) { + lineStarts.push(position) + } + } + return lineStarts +} + +const CARRIAGE_RETURN_CODE = 0x0d +const LINE_FEED_CODE = 0x0a + +function offsetToPosition(offset: number, lineStarts: readonly number[]): ts.LineAndCharacter { + const line = findLine(offset, lineStarts) + return { line, character: offset - lineStarts[line] } +} + +function positionToOffset(position: ts.LineAndCharacter, lineStarts: readonly number[]): number { + return lineStarts[clamp(position.line, 0, lineStarts.length - 1)] + position.character +} + +/** Binary search for the last line whose start is at or before the offset. */ +function findLine(offset: number, lineStarts: readonly number[]): number { + let low = 0 + let high = lineStarts.length - 1 + while (low < high) { + const mid = (low + high + 1) >> 1 + if (lineStarts[mid] <= offset) { + low = mid + } else { + high = mid - 1 + } + } + return low +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +function isAfter(left: ts.LineAndCharacter, right: ts.LineAndCharacter): boolean { + return left.line > right.line || (left.line === right.line && left.character > right.character) +} + +export function positionsEqual(left: ts.LineAndCharacter, right: ts.LineAndCharacter): boolean { + return left.line === right.line && left.character === right.character +} diff --git a/src/virtual-document/virtual-document-session-provider.ts b/src/virtual-document/virtual-document-session-provider.ts new file mode 100644 index 0000000..31a39ed --- /dev/null +++ b/src/virtual-document/virtual-document-session-provider.ts @@ -0,0 +1,77 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type { Stylesheet } from 'vscode-css-languageservice' +import type { TextDocument } from 'vscode-languageserver-textdocument' + +import type { ScssLanguageService } from '../features/styles-language-services.ts' +import type { VirtualDocumentProvider } from './styled-virtual-document-provider.ts' +import { createTemplateLineMap, type TemplateLineMap } from './template-line-map.ts' + +export interface VirtualDocument { + readonly document: TextDocument + readonly lineMap: TemplateLineMap +} + +export interface ParsedVirtualDocument extends VirtualDocument { + readonly stylesheet: Stylesheet +} + +/** One cached virtual document per service, reused under canReuseVirtualDocument (docs/architecture.md, "Caching"). */ +export interface VirtualDocumentSessionProvider { + getDocument(context: TemplateContext): VirtualDocument + getParsedDocument(context: TemplateContext): ParsedVirtualDocument + /** + * The cached TemplateLineMap when the cached document is reusable for `context`, otherwise + * undefined; never builds a document, so a caller that needs only positions (a diagnostics cache + * hit) neither pays for nor evicts one. + */ + getReusableLineMap(context: TemplateContext): TemplateLineMap | undefined +} + +interface CachedDocument extends VirtualDocument { + readonly context: TemplateContext + stylesheet?: Stylesheet +} + +export class CachedVirtualDocumentSessionProvider implements VirtualDocumentSessionProvider { + private cached?: CachedDocument + + public constructor( + private readonly virtualDocumentProvider: VirtualDocumentProvider, + private readonly scssLanguageService: ScssLanguageService, + ) {} + + public getDocument(context: TemplateContext): VirtualDocument { + const { document, lineMap } = this.ensureDocument(context) + return { document, lineMap } + } + + public getParsedDocument(context: TemplateContext): ParsedVirtualDocument { + const cached = this.ensureDocument(context) + cached.stylesheet ??= this.scssLanguageService.parseStylesheet(cached.document) + return { document: cached.document, lineMap: cached.lineMap, stylesheet: cached.stylesheet } + } + + public getReusableLineMap(context: TemplateContext): TemplateLineMap | undefined { + return this.reusableCache(context)?.lineMap + } + + private reusableCache(context: TemplateContext): CachedDocument | undefined { + const { cached } = this + return cached && this.virtualDocumentProvider.canReuseVirtualDocument?.(cached.context, context) + ? cached + : undefined + } + + private ensureDocument(context: TemplateContext): CachedDocument { + const reusable = this.reusableCache(context) + if (reusable) { + return reusable + } + this.cached = { + context, + document: this.virtualDocumentProvider.createVirtualDocument(context), + lineMap: createTemplateLineMap(context), + } + return this.cached + } +} diff --git a/test/unit/bounded-worker-noop-fixture.ts b/test/unit/bounded-worker-noop-fixture.ts new file mode 100644 index 0000000..c790864 --- /dev/null +++ b/test/unit/bounded-worker-noop-fixture.ts @@ -0,0 +1,12 @@ +/** + * A worker entry with a handler that never resolves on its own: bounded-worker.test.ts drives the + * settle logic directly through BoundedWorker's internal state, so the only thing this file needs + * to provide is a real, terminable worker thread to run it in. + */ +import { isMainThread } from 'node:worker_threads' + +import { serveBounded } from '../../scripts/bounded-worker.ts' + +if (!isMainThread) { + serveBounded(() => new Promise(() => {})) +} diff --git a/test/unit/bounded-worker.test.ts b/test/unit/bounded-worker.test.ts new file mode 100644 index 0000000..f6600fc --- /dev/null +++ b/test/unit/bounded-worker.test.ts @@ -0,0 +1,80 @@ +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { assert, describe, it } from 'vitest' + +import { BoundedWorker } from '../../scripts/bounded-worker.ts' + +const NOOP_FIXTURE_ENTRY = pathToFileURL(path.resolve(__dirname, 'bounded-worker-noop-fixture.ts')) + +/** Reaches past BoundedWorker's own encapsulation to drive the exact message ordering under test: the settle-once rule is a race between two real async event sources (a worker thread's message and its exit), which real timing cannot reproduce deterministically. */ +interface BoundedWorkerInternals { + handleMessage(message: { readonly result: string; readonly type: 'result' }): void + running?: { deadlineHit: boolean } +} + +describe('BoundedWorker', () => { + it('resolves done for a normal result reported before any deadline', async () => { + const worker = new BoundedWorker({ + entry: NOOP_FIXTURE_ENTRY, + heapMb: 64, + }) + try { + const runPromise = worker.run(undefined, { deadlineMs: 60_000 }) + await new Promise((resolve) => setImmediate(resolve)) + const internals = worker as unknown as BoundedWorkerInternals + assert.isDefined(internals.running, 'run() must populate its running state synchronously') + internals.handleMessage({ result: 'on-time result', type: 'result' }) + + const outcome = await runPromise + assert.strictEqual(outcome.kind, 'done') + assert.strictEqual(outcome.kind === 'done' ? outcome.result : undefined, 'on-time result') + } finally { + await worker.close() + } + }) + + it('settles a result that arrives after the deadline as the deadline stop, never as done', async () => { + const worker = new BoundedWorker({ + entry: NOOP_FIXTURE_ENTRY, + heapMb: 64, + }) + try { + const runPromise = worker.run(undefined, { deadlineMs: 60_000 }) + await new Promise((resolve) => setImmediate(resolve)) + const internals = worker as unknown as BoundedWorkerInternals + assert.isDefined(internals.running, 'run() must populate its running state synchronously') + + /** + * Simulates the deadline timer firing (worker.terminate() requested) immediately followed by + * the worker's own 'result' message for the task it was already running, arriving before the + * worker has actually exited: a real race between the timer callback and the message event, + * both delivered on the parent's event loop. + */ + internals.running.deadlineHit = true + internals.handleMessage({ result: 'late result', type: 'result' }) + + const stillPending = Symbol('still-pending') + const raced = await Promise.race([ + runPromise, + new Promise((resolve) => setTimeout(() => resolve(stillPending), 50)), + ]) + assert.strictEqual( + raced, + stillPending, + 'a message after the deadline must not settle the outcome by itself', + ) + + await worker.close() + const outcome = await runPromise + assert.strictEqual( + outcome.kind, + 'deadline', + "only the worker's real exit settles the task, and it must read as the deadline stop, " + + 'never as the late "done" result', + ) + } finally { + await worker.close() + } + }) +}) diff --git a/test/unit/changelog.test.ts b/test/unit/changelog.test.ts new file mode 100644 index 0000000..6ccc813 --- /dev/null +++ b/test/unit/changelog.test.ts @@ -0,0 +1,388 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, assert, describe, it, vi } from 'vitest' + +import { + CHANGESET_DIRECTORY, + attribution, + getDependencyReleaseLine, + getReleaseLine, + isAgent, + isMaintainer, +} from '../../scripts/changelog.cjs' +import type { GitIdentity } from '../../scripts/changelog.cjs' + +const CHANGESET_PACKAGE_NAME = '@styled/typescript-styled-plugin' +const SCRIPTS_DIRECTORY = path.resolve(__dirname, '../../scripts') + +const ADA: GitIdentity = { email: 'ada@example.com', name: 'Ada Author' } +const BEA: GitIdentity = { email: 'bea@example.com', name: 'Bea Maintainer' } +const CORA: GitIdentity = { email: 'cora@example.com', name: 'Cora Helper' } +const DAN: GitIdentity = { email: 'dan@example.com', name: 'Dan Fixer' } + +const MAINTAINERS = ['Bea Maintainer', 'ej@quantizor.dev', 'quantizor'] + +let repoDir: string | undefined + +afterEach(() => { + vi.restoreAllMocks() + if (repoDir) { + rmSync(repoDir, { force: true, recursive: true }) + repoDir = undefined + } +}) + +/** A fresh temporary directory, removed after the test; not a git repository. */ +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'changelog-test-')) + repoDir = dir + return dir +} + +/** Makes `dir` the directory getReleaseLine's git queries run in. */ +function runIn(dir: string) { + vi.spyOn(process, 'cwd').mockReturnValue(dir) +} + +/** + * Runs `git` inside `cwd` with a fixed author/committer identity, independent of the host's global + * git config (name, email, commit signing), so the fixture is deterministic wherever it runs. + */ +function git(cwd: string, args: string[], identity: GitIdentity = ADA): string { + return execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_EMAIL: identity.email, + GIT_AUTHOR_NAME: identity.name, + GIT_COMMITTER_EMAIL: identity.email, + GIT_COMMITTER_NAME: identity.name, + }, + }) +} + +/** A fresh, empty git repository with no commits at all: the next commit made in it is parentless. */ +function initBareRepo(): string { + const dir = tempDir() + git(dir, ['init', '--initial-branch=main', '-q']) + mkdirSync(path.join(dir, CHANGESET_DIRECTORY)) + return dir +} + +/** + * A throwaway repository with one settled root commit, so every fixture commit added afterward has + * a parent (`attribution`'s `trustworthy` stays true, the ordinary case away from a shallow clone). + */ +function initRepo(): string { + const dir = initBareRepo() + writeFileSync(path.join(dir, 'README.md'), '# fixture\n') + git(dir, ['add', 'README.md']) + git(dir, ['commit', '-q', '-m', 'chore: root commit']) + return dir +} + +/** + * Writes the changeset `id` with `body` and commits it as `identity` with `message`, returning the + * commit's abbreviated hash. + */ +function commitChangeset( + dir: string, + change: { body: string; id: string; identity?: GitIdentity; message: string }, +): string { + const relativePath = `${CHANGESET_DIRECTORY}/${change.id}.md` + const identity = change.identity ?? ADA + writeFileSync( + path.join(dir, relativePath), + `---\n"${CHANGESET_PACKAGE_NAME}": patch\n---\n\n${change.body}\n`, + ) + git(dir, ['add', relativePath], identity) + git(dir, ['commit', '-q', '-m', change.message], identity) + return git(dir, ['rev-parse', '--short', 'HEAD']).trim() +} + +function coAuthored(subject: string, ...people: GitIdentity[]): string { + return `${subject}\n\n${people.map((person) => `Co-authored-by: ${person.name} <${person.email}>`).join('\n')}` +} + +describe('attribution', () => { + it('cites every commit that added or modified the file, oldest first, deduplicated by email', () => { + const dir = initRepo() + const id = 'brave-lions-jump' + + const addSha = commitChangeset(dir, { + body: 'First draft.', + id, + message: 'chore: add changeset', + }) + const refineSha = commitChangeset(dir, { + body: 'First draft, refined.', + id, + identity: BEA, + message: coAuthored('chore: refine changeset', CORA), + }) + + const result = attribution(`${CHANGESET_DIRECTORY}/${id}.md`, { cwd: dir }) + + assert.isTrue(result.trustworthy) + assert.deepEqual(result.commits, [addSha, refineSha]) + assert.deepEqual( + result.people.map((person) => ({ + email: person.email, + name: person.name, + sha: person.sha, + via: person.via, + })), + [ + { email: ADA.email, name: ADA.name, sha: addSha, via: 'author' }, + { email: BEA.email, name: BEA.name, sha: refineSha, via: 'author' }, + { email: CORA.email, name: CORA.name, sha: refineSha, via: 'co-author' }, + ], + ) + }) + + it('marks a parentless (root) commit as untrustworthy, the shallow-clone boundary signature', () => { + const dir = initBareRepo() + const id = 'lonely-otters-fly' + + commitChangeset(dir, { body: 'Fixes a bug.', id, message: 'root commit' }) + + const result = attribution(`${CHANGESET_DIRECTORY}/${id}.md`, { cwd: dir }) + + assert.isFalse(result.trustworthy) + assert.lengthOf(result.commits, 1) + }) + + it('returns an empty, untrustworthy result when the git query itself fails', () => { + /** + * No `git init`: the query fails outright ("fatal: not a git repository"), the case this + * guards, distinct from a real repo with no history for the path (which git answers with an + * empty, but successful, result). + */ + const dir = tempDir() + + const result = attribution(`${CHANGESET_DIRECTORY}/never-existed.md`, { cwd: dir }) + + assert.isFalse(result.trustworthy) + assert.deepEqual(result.commits, []) + assert.deepEqual(result.people, []) + assert.match(result.error ?? '', /not a git repository/) + }) + + it("keeps git's stderr off the terminal when the git query fails", () => { + const dir = tempDir() + const changelog = path.join(SCRIPTS_DIRECTORY, 'changelog.cjs') + const probe = `const { attribution } = require(${JSON.stringify(changelog)}); process.stdout.write(attribution('.changeset/x.md', { cwd: ${JSON.stringify(dir)} }).error)` + + const result = spawnSync(process.execPath, ['-e', probe], { encoding: 'utf8' }) + + assert.strictEqual(result.status, 0) + assert.match(result.stdout, /not a git repository/) + assert.strictEqual(result.stderr, '') + }) + + it('returns a trustworthy, empty result for a real repository with no history for the path', () => { + const dir = initRepo() + + const result = attribution(`${CHANGESET_DIRECTORY}/never-existed.md`, { cwd: dir }) + + assert.isTrue(result.trustworthy) + assert.deepEqual(result.commits, []) + assert.deepEqual(result.people, []) + }) +}) + +describe('isAgent', () => { + it('flags a "[bot]" name suffix regardless of case', () => { + assert.isTrue(isAgent({ email: 'x@example.com', name: 'dependabot[BOT]' })) + }) + + it('flags each configured agent email fragment', () => { + assert.isTrue(isAgent({ email: 'copilot@github.com', name: 'Copilot' })) + assert.isTrue(isAgent({ email: 'noreply+cursoragent@cursor.sh', name: 'Cursor' })) + assert.isTrue(isAgent({ email: 'devin-ai-integration[bot]@devin.ai', name: 'Devin' })) + assert.isTrue(isAgent({ email: 'noreply@anthropic.com', name: 'Claude' })) + }) + + it('never flags a person by name alone, even when the name matches an agent brand', () => { + assert.isFalse(isAgent({ email: 'claude@example.com', name: 'Claude' })) + }) + + it('does not flag an ordinary contributor', () => { + assert.isFalse(isAgent(ADA)) + }) +}) + +describe('isMaintainer', () => { + it('matches by exact name, case-insensitively', () => { + assert.isTrue( + isMaintainer({ email: 'someone@example.com', name: 'bea maintainer' }, MAINTAINERS), + ) + }) + + it('matches by exact email', () => { + assert.isTrue(isMaintainer({ email: 'ej@quantizor.dev', name: 'E J' }, MAINTAINERS)) + }) + + it('matches an email containing a configured entry as a substring', () => { + assert.isTrue( + isMaintainer( + { email: '570070+quantizor@users.noreply.github.com', name: 'GitHub Noreply' }, + MAINTAINERS, + ), + ) + }) + + it('does not match a name that only contains a configured entry', () => { + assert.isFalse(isMaintainer({ email: 'x@example.com', name: 'Not quantizor' }, MAINTAINERS)) + }) + + it('does not match an unrelated contributor', () => { + assert.isFalse(isMaintainer(ADA, MAINTAINERS)) + }) +}) + +describe('getReleaseLine', () => { + it('cites every commit oldest-first and thanks non-maintainer, non-agent contributors once, excluding a name already in the summary', async () => { + const dir = initRepo() + const id = 'brave-lions-jump' + const summary = 'Adds a thing. Thanks Cora Helper for the report.' + + const addSha = commitChangeset(dir, { + body: 'Adds a thing.', + id, + message: 'chore: add changeset', + }) + const refineSha = commitChangeset(dir, { + body: summary, + id, + identity: BEA, + message: coAuthored('chore: refine', CORA), + }) + runIn(dir) + + const line = await getReleaseLine({ commit: undefined, id, releases: [], summary }, 'patch', { + maintainers: MAINTAINERS, + }) + + /** + * Bea is a configured maintainer and Cora's name is already in the summary, so only Ada (the + * changeset's actual reporter-turned-author) is thanked. + */ + assert.equal(line, `- ${addSha}, ${refineSha}: ${summary}\n\n Thanks Ada Author!`) + }) + + it('joins three or more names with an Oxford comma, via Intl.ListFormat', async () => { + const dir = initRepo() + const id = 'three-friends-help' + const summary = 'Adds another thing.' + + const sha = commitChangeset(dir, { + body: summary, + id, + message: coAuthored('chore: add changeset', CORA, DAN), + }) + runIn(dir) + + const line = await getReleaseLine({ commit: undefined, id, releases: [], summary }, 'patch', { + maintainers: [], + }) + + assert.equal(line, `- ${sha}: ${summary}\n\n Thanks Ada Author, Cora Helper, and Dan Fixer!`) + }) + + it('falls back to changeset.commit and warns once when the changeset was added by a parentless commit', async () => { + const dir = initBareRepo() + const id = 'lonely-otters-fly' + const summary = 'Fixes a bug.' + + commitChangeset(dir, { body: summary, id, message: 'root commit' }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + runIn(dir) + + const line = await getReleaseLine( + { commit: 'deadbeefcafefeed', id, releases: [], summary }, + 'patch', + { maintainers: [] }, + ) + + /** + * Untrustworthy attribution carries no thanks line at all, not even an empty one: the + * shallow-clone fallback only knows the single commit changesets itself resolved. + */ + assert.equal(line, `- deadbee: ${summary}`) + assert.equal(warnSpy.mock.calls.length, 1) + assert.match( + String(warnSpy.mock.calls[0]?.[0]), + /\.changeset\/lonely-otters-fly\.md has a parentless commit in its history, which means a shallow clone/, + ) + }) + + it("falls back to changeset.commit and warns with git's error when the git query fails", async () => { + const summary = 'Fixes a bug.' + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + runIn(tempDir()) + + const line = await getReleaseLine( + { commit: 'deadbeefcafefeed', id: 'lonely-otters-fly', releases: [], summary }, + 'patch', + { maintainers: [] }, + ) + + assert.equal(line, `- deadbee: ${summary}`) + assert.equal(warnSpy.mock.calls.length, 1) + assert.match( + String(warnSpy.mock.calls[0]?.[0]), + /^changelog: git could not read the history of \.changeset\/lonely-otters-fly\.md[\s\S]*not a git repository/, + ) + }) + + it('cites nothing and skips thanks when the changeset has no resolvable commit at all', async () => { + runIn(initRepo()) + + const line = await getReleaseLine( + { commit: undefined, id: 'never-committed', releases: [], summary: 'Untracked change.' }, + 'patch', + { maintainers: [] }, + ) + + assert.equal(line, '- Untracked change.') + }) +}) + +describe('getDependencyReleaseLine', () => { + it('returns an empty string when no dependencies changed', async () => { + assert.equal(await getDependencyReleaseLine([], []), '') + }) + + it('lists the changeset commit and each updated dependency', async () => { + const result = await getDependencyReleaseLine( + [{ commit: 'abc1234567890' }], + [{ name: CHANGESET_PACKAGE_NAME, newVersion: '1.2.3' }], + ) + + assert.equal(result, `- Updated dependencies [abc1234]\n - ${CHANGESET_PACKAGE_NAME}@1.2.3`) + }) +}) + +describe('changeset-credits', () => { + it("fails with git's error instead of reporting no history when the git query fails", () => { + const script = path.join(SCRIPTS_DIRECTORY, 'changeset-credits.ts') + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', '--no-warnings', script, 'completions'], + { + encoding: 'utf8', + env: { ...process.env, GIT_DIR: path.join(tmpdir(), 'changeset-credits-missing-git-dir') }, + }, + ) + + assert.notEqual(result.status, 0) + assert.match(result.stderr, /git could not read the history of \.changeset\/completions\.md/) + assert.match(result.stderr, /not a git repository/) + assert.notInclude(result.stdout, 'no history') + }) +}) diff --git a/test/unit/create-template-context.ts b/test/unit/create-template-context.ts new file mode 100644 index 0000000..1331f43 --- /dev/null +++ b/test/unit/create-template-context.ts @@ -0,0 +1,63 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import * as ts from 'typescript/lib/tsserverlibrary.js' + +/** + * A TemplateContext for `text` as the whole body of one template tagged `tagName` (the tag + * expression written before the backtick, such as `css` or `styled.div`), in a source file holding + * only that template. `text` is both the substituted and the raw text. Positions route through the + * source file's own line map (TypeScript's computeLineStarts, which treats U+2028 and U+2029 as + * line terminators like the plugin's line-start table does), so a position after a Unicode line or + * paragraph separator is exact rather than silently wrong. `toOffset` asserts on a line past the + * file, as tsserver does for a closed file. + */ +export function createTemplateContext( + text: string, + tagName = 'css', + fileName = 'fixture.ts', +): TemplateContext { + const sourceFile = ts.createSourceFile( + fileName, + `const styles = ${tagName}\`${text}\`;`, + ts.ScriptTarget.Latest, + true, + ) + const statement = sourceFile.statements[0] + if (!statement || !ts.isVariableStatement(statement)) { + throw new Error('Expected a variable statement.') + } + const initializer = statement.declarationList.declarations[0]?.initializer + if (!initializer || !ts.isTaggedTemplateExpression(initializer)) { + throw new Error('Expected a tagged template expression.') + } + const bodyStart = initializer.template.getStart(sourceFile) + 1 + const bodyStartPosition = sourceFile.getLineAndCharacterOfPosition(bodyStart) + + return { + typescript: ts, + fileName: sourceFile.fileName, + node: initializer.template, + text, + rawText: text, + toPosition(offset) { + const position = sourceFile.getLineAndCharacterOfPosition(bodyStart + offset) + return { + line: position.line - bodyStartPosition.line, + character: + position.line === bodyStartPosition.line + ? position.character - bodyStartPosition.character + : position.character, + } + }, + toOffset(position) { + const line = bodyStartPosition.line + position.line + return ( + sourceFile.getPositionOfLineAndCharacter( + line, + position.line === 0 + ? bodyStartPosition.character + position.character + : position.character, + ) - bodyStart + ) + }, + } +} diff --git a/test/unit/javascript-escapes.test.ts b/test/unit/javascript-escapes.test.ts new file mode 100644 index 0000000..89237a3 --- /dev/null +++ b/test/unit/javascript-escapes.test.ts @@ -0,0 +1,425 @@ +import { assert, describe, it } from 'vitest' + +import { + getTemplateCssText, + getTemplateEscapeRuns, + replaceJavaScriptEscapes, +} from '../../src/virtual-document/javascript-escapes' + +/** + * Each input is raw template source text: a backslash here is a JavaScript escape, as in a + * template literal. BACKSLASH builds the `\u` forms so no Unicode escape is read by this file's own + * parser. + */ +const BACKSLASH = String.fromCharCode(92) + +describe('replaceJavaScriptEscapes', () => { + it.each([ + ['an escaped double quote', 'content: \\"x\\";', 'content: "x ";'], + ['an escaped single quote', "content: \\'x\\';", "content: 'x ';"], + ['two escaped backslashes in a row', '\\\\\\\\b', ' \\\\b'], + ['an escaped backtick', 'content: "\\`";', 'content: " `";'], + ['an escaped dollar sign before a brace', 'content: "\\${x}";', 'content: " ${x}";'], + ['a character that needs no escape', '\\a', ' a'], + ['an escaped surrogate pair', '\\😀', ' 😀'], + ['a hex escape', '\\x41', ' A'], + ['a four-digit Unicode escape', `${BACKSLASH}u00e9`, ' é'], + ['a code point escape', `${BACKSLASH}u{1F600}`, ' 😀'], + [ + 'a surrogate pair written as two escapes', + `${BACKSLASH}uD83D${BACKSLASH}uDE00`, + ' 😀', + ], + ['a character that does not continue the name before it', 'a\\;b', 'a ;b'], + ])('should write %s as its cooked character, right-aligned', (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }) + + /** + * A cooked backslash escapes the character after it, as a CSS escape. Where CSS allows, the + * stand-in spends the padding inside that escape instead of as a space that would split the + * name or url() around it: as leading zeros of a hex escape, or as the escaped character's own + * hex code. Otherwise the padding moves in front of the name the escape sits in, or is a space + * before the backslash when no name comes before it. + */ + it.each([ + ['a hex code point in a string', '"\\\\f101"', '"\\0f101"'], + ['a one-digit hex escape in a url()', 'url(a\\\\b.png)', 'url(a\\0b.png)'], + ['a hex escape followed by a space', '\\\\31 0', '\\031 0'], + ['a letter inside a name', 'u\\\\rl(', 'u\\72l('], + ['a quote before a non-hex character', '"a\\\\":b"', '"a\\22:b"'], + ['a character after three backslashes', 'a\\\\\\\\\\\\:x', 'a\\\\\\003ax'], + ['a colon before a hex digit, which a hex escape would absorb', '.md\\\\:flex', ' .md\\:flex'], + ['a colon before a space, which a hex escape would absorb', 'a\\\\: b', ' a\\: b'], + ['a character at the end of the text', 'a\\\\@', ' a\\@'], + ['a character before another escape', 'u\\\\r\\\\l', ' u\\r\\l'], + ['six hex digits, too many to take a leading zero', '\\\\abcdef', ' \\abcdef'], + ['a line break, which CSS does not escape', 'a\\\\\nb', ' a\\\nb'], + ['a character whose code fits in two hex digits', '\\\\é!', '\\e9!'], + ['a character whose code needs more hex digits than the room', '\\\\中!', ' \\中!'], + ['hex digits a later run continues', '"\\\\4\\x31"', '"\\000041"'], + /** Joined, the zeros and digits would pass six, so each run keeps its own stand-in. */ + ['hex digits a later run continues past six', 'x\\\\fa\\x34;', 'x\\0fa\\034;'], + ])('should write an escaped backslash before %s', (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }) + + /** + * Inside an unquoted url() a space would split the url token, so where the escape cannot absorb + * the padding, the padding becomes "_", a character a url token holds, after the cooked text and + * before a trailing backslash. + */ + it.each([ + ['a paren before a hex digit', 'url(a\\\\(b\\\\).png)', 'url(a_\\(b\\29.png)'], + ['a paren before a hex digit after leading whitespace', 'url( a\\\\(b)', 'url( a_\\(b)'], + ['two parens before hex digits', 'url(a\\\\(b\\\\(c)', 'url(a_\\(b_\\(c)'], + ['a url-prefix() argument', 'url-prefix(a\\\\(b)', 'url-prefix(a_\\(b)'], + ['a url() written in capitals', 'URL(a\\\\(b)', 'URL(a_\\(b)'], + ['a letter after "/"', 'url(a/\\x41y.png)', 'url(a/A___y.png)'], + ['a letter after "."', 'url(a.\\x41)', 'url(a.A___)'], + ['a name at the start of the argument', 'url(a\\x62.png)', 'url(ab___.png)'], + ['a name after "/"', 'url(x/a\\x62.png)', 'url(x/ab___.png)'], + ['a name after ","', 'url(a,b\\x63.png)', 'url(a,bc___.png)'], + ['a name after ";"', 'url(a;b\\x63.png)', 'url(a;bc___.png)'], + ['a run of a non-name character', 'url(a\\x2Fb.png)', 'url(a/___b.png)'], + ['two runs in one name', 'url(a\\x62c\\x64.png)', 'url(ab___cd___.png)'], + ['a line continuation before a line feed', 'url(foo\\\nbar.png)', 'url(foo__bar.png)'], + ['a line continuation before a CRLF', 'url(foo\\\r\nbar.png)', 'url(foo___bar.png)'], + /** + * A character JavaScript's `\s` matches but CSS does not count as whitespace is url content, so + * the run after it is still inside the argument. + */ + [ + 'a run after a cooked U+3000', + `url(a${BACKSLASH}u3000,${BACKSLASH}x41)`, + `url(a${String.fromCharCode(0x3000)}_____,A___)`, + ], + [ + 'a run after a cooked U+00A0', + 'url(a\\xA0,\\x41)', + `url(a${String.fromCharCode(0xa0)}___,A___)`, + ], + [ + 'a run after a cooked U+FEFF', + `url(a${BACKSLASH}uFEFF,${BACKSLASH}x41)`, + `url(a${String.fromCharCode(0xfeff)}_____,A___)`, + ], + [ + 'a run after a cooked U+0085', + 'url(a\\x85,\\x41)', + `url(a${String.fromCharCode(0x85)}___,A___)`, + ], + /** A quote, paren, or whitespace a CSS escape takes is url content too. */ + ['a run after a CSS-escaped double quote', 'url(a\\\\"b\\x41)', 'url(a_\\"bA___)'], + ['a run after a CSS-escaped single quote', "url(a\\\\'b\\x41)", "url(a_\\'bA___)"], + [ + 'a run after a quote a hex-escaped backslash escapes', + 'url(a\\x5c"b\\x41)', + 'url(a___\\"bA___)', + ], + [ + 'a run after a paren a hex-escaped backslash escapes', + 'url(a\\x5c)b\\x41)', + 'url(a___\\)bA___)', + ], + [ + 'a run after a CSS-escaped quote inside the cooked text', + 'url(a\\\\\\"b\\x41)', + 'url(a\\"__bA___)', + ], + ['a run after a CSS-escaped space', 'url(a\\\\ b\\x41)', 'url(a_\\ bA___)'], + ['a run after the space a hex escape takes', 'url(a\\\\41 b\\x41)', 'url(a\\041 bA___)'], + /** A hex escape takes the cooked line break after its digits, so the fill follows that line break. */ + [ + 'a run whose hex escape takes its cooked line break', + 'url(\\x5c\\x31\\n\\\\f)', + 'url(\\1 ________\\f)', + ], + /** + * A CSS hex escape a run continues takes all its digits and the whitespace after them, so the + * padding goes where it splits neither: as leading zeros after the backslash while the escape + * stays within six hex digits, and otherwise as "_" before the backslash. + */ + ['a hex escape a later run continues', 'url(a\\\\4\\x31 b\\x41)', 'url(a\\000041 bA___)'], + [ + 'a hex escape a later run continues past six digits with the zeros', + 'url(a\\\\4\\x31\\x32 b\\x41)', + 'url(a_______\\412 bA___)', + ], + [ + 'a five-digit hex escape a later run continues', + 'url(a\\\\1234\\x35 b\\x41)', + 'url(a____\\12345 bA___)', + ], + [ + 'a hex escape written whole by one run', + 'url(a\\\\\\x34\\x31 b\\x41)', + 'url(a_______\\41 bA___)', + ], + ])( + 'should pad an escape inside an unquoted url() with "_" for %s', + (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }, + ) + + /** + * Padding written after a run's own cooked whitespace would let the CSS scanner end the url token + * at that whitespace and read the padding as a second, invalid token (vscode-css-languageservice's + * `_unquotedChar`, cssScanner.js, excludes whitespace, quotes, and parens from an unquoted url() + * character, so any of them ends the token there; `_parseURLArgument` then accepts only one token + * before requiring the closing paren). The padding goes before the run's own trailing whitespace + * instead, so it stays part of the token the whitespace ends. A cooked quote opening the argument's + * first content (nothing but whitespace precedes it back to the "(") makes the whole argument a + * quoted string, which `_parseURLArgument` tries before an unquoted one: the padding is spaces + * outside url() and out of the fill. + */ + it.each([ + ['a run that cooks to a trailing line break', 'url(x\\n)', 'url(x_ )'], + ['a run that cooks to a trailing space', 'url(a.png\\x20)', 'url(a.png___ )'], + ['a run that cooks to a trailing line break, after another', 'url(a\\n)', 'url(a_ )'], + ['a quote opening the argument right after "("', 'url(\\x22\\x22)', 'url( "")'], + [ + 'a run that cooks to leading whitespace only, with real content later in the same run', + 'url(\\x204a)', + 'url( 4a)', + ], + ])('should keep a valid unquoted url() valid for %s', (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }) + + it.each([ + ['a quoted url()', 'url("a\\\\(b")', 'url("a \\(b")'], + ['a function whose name only ends in url', 'image-url(a\\\\(b)', 'image-url( a\\(b)'], + ['a function whose name ends in url after a non-ASCII letter', 'äurl(a\\\\(b)', 'äurl( a\\(b)'], + ['a url() closed before the escape', 'url(a) b\\\\(c', 'url(a) b\\(c'], + ['a url() argument with whitespace inside it', 'url(a b\\\\(c)', 'url(a b\\(c)'], + ['a paren that is not a url()', 'x(a\\\\(b)', 'x( a\\(b)'], + ['a url name after "#", a hash', '#url(a,b\\x41)', '#url(a, bA)'], + /** A url function name must be written literally, so a name spelled with an escape opens no url(). */ + ['a url name spelled with an escape', 'u\\x72l(a,b\\x41)', ' url(a, bA)'], + ['a url name spelled with a CSS escape', 'u\\\\rl(a,b\\x41)', 'u\\72l(a, bA)'], + ['a url name joined to an escape run before it', '\\x20url(a,b\\x41)', ' url(a, bA)'], + /** The cooked text of an earlier run ends the argument: a runtime url() breaks there too. */ + ['a url() whose earlier run cooks to a space', 'url(a\\x20,\\x41)', 'url(a___ , A)'], + [ + 'a url() whose earlier run cooks to a vertical tab, written as a space', + 'url(a\\x0B,\\x41)', + 'url(a___ , A)', + ], + ['a url() whose earlier run cooks to an unescaped quote', 'url(a\\",\\x41)', 'url(a"_, A)'], + [ + 'a url() whose earlier run ends with a backslash before a line break', + 'url(a\\\\\n,\\x41)', + 'url(a_\\\n, A)', + ], + ['a url() with two spaces after a hex escape', 'url(a\\\\41 b\\x41)', 'url(a\\041 bA)'], + ])( + 'should pad an escape with a space outside an unquoted url(), in %s', + (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }, + ) + + /** + * An escape run inside a name keeps the name one identifier with its cooked spelling: the padding + * moves in front of the name, where whitespace changes nothing. + */ + it.each([ + ['a letter inside a property name', 'co\\x6Cr: red;', ' colr: red;'], + ['a letter inside a known property name', 'ba\\x63kground: red;', ' background: red;'], + ['a letter before a hex digit', 'bor\\x64er: 0;', ' border: 0;'], + ['a letter inside a value keyword', 'color: r\\x65d;', 'color: red;'], + ['a Unicode escape inside a name', `r${BACKSLASH}u0065d`, ' red'], + ['two runs inside one name', '\\x63ol\\x6Fr', ' color'], + ['a digit inside a number', '1\\x30px', ' 10px'], + ['a hex color', 'color: #f\\x66f;', 'color: #fff;'], + ['a class name', '.bt\\x6E {', ' .btn {'], + ['an at-keyword', '@me\\x64ia', ' @media'], + ['"!important"', 'red !imp\\x6Frtant', 'red !important'], + ['a name after an escaped CSS colon', '.md\\\\:fl\\x65x', ' .md\\:flex'], + ['a name after "("', 'var(--a\\x62)', 'var( --ab)'], + /** + * A run that cooks to exactly "(" right after a name opens that name as a function call: url() + * and url-prefix() specifically require no whitespace before the "(" (unlike a general function, + * which tolerates it), so the padding moves in front of the name instead of staying between the + * name and the call. + */ + ['a url() name right before a cooked "("', 'url\\x28a)', ' url(a)'], + ['a general function name right before a cooked "("', 'rgb\\x28 0,0,0)', ' rgb( 0,0,0)'], + ])('should move the padding in front of %s', (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }) + + /** + * Where whitespace in front of the name would split a larger token (`&.a`, `a:hover`), the last + * cooked character becomes a CSS hex escape that fills the padding instead, ended by a space when + * the character after it would continue or end the hex digits. + */ + it.each([ + ['a class joined to "&", before a non-hex character', '&.bt\\x6E{', '&.bt\\06e{'], + ['a class joined to "&", before a hex digit', '&.bor\\x64er{', '&.bor\\64 er{'], + ['a pseudo-class', 'a:ho\\x76er', 'a:ho\\76 er'], + ['a value right after ":"', 'color:r\\x65d;', 'color:r\\65 d;'], + ['a non-ASCII letter', `&.a${BACKSLASH}u00e9b`, '&.a\\00e9 b'], + ['a name inside a string', '"a\\x62"', '"a\\062"'], + ])('should write a CSS hex escape for %s', (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }) + + /** An escaped digit starts an identifier rather than continuing a number or hash, and an astral character's code does not fit. */ + it.each([ + ['a hash right after ":"', 'color:#f\\x66f;', 'color:#f ff;'], + ['a number right after ":"', 'margin:1\\x30px;', 'margin:1 0px;'], + ['a character outside the Basic Multilingual Plane', `&.a${BACKSLASH}u{1F600}`, '&.a 😀'], + ])('should leave the padding before the cooked text for %s', (_description, raw, expected) => { + const result = replaceJavaScriptEscapes(raw) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, raw.length) + }) + + it.each([ + ['a line feed', '\\n'], + ['a carriage return', '\\r'], + ['a tab', '\\t'], + ['a vertical tab', '\\v'], + ['a form feed', '\\f'], + ['a backspace', '\\b'], + ['a null character', '\\0'], + ['a hex-escaped control character', '\\x1b'], + ['an escaped line separator', `${BACKSLASH}u2028`], + ['an escaped paragraph separator', `${BACKSLASH}u2029`], + ])('should write %s as spaces', (_description, escape) => { + const raw = `a${escape}b` + + assert.strictEqual(replaceJavaScriptEscapes(raw), `a${' '.repeat(escape.length)}b`) + }) + + it.each([ + ['a line feed', '\n'], + ['a CRLF', '\r\n'], + ['a lone carriage return', '\r'], + ['a line separator', String.fromCharCode(0x2028)], + ['a paragraph separator', String.fromCharCode(0x2029)], + ])('should write a line continuation before %s as spaces', (_description, lineBreak) => { + const raw = `color: red;\\${lineBreak}margin: 0;` + + assert.strictEqual( + replaceJavaScriptEscapes(raw), + `color: red;${' '.repeat(1 + lineBreak.length)}margin: 0;`, + ) + }) + + it.each([ + ['a digit other than zero', '\\1'], + ['zero followed by a digit', '\\01'], + ['a hex escape with one digit', '\\x4g'], + ['a four-digit Unicode escape with three digits', `${BACKSLASH}u12g4`], + ['a code point escape past U+10FFFF', `${BACKSLASH}u{110000}`], + ['an empty code point escape', `${BACKSLASH}u{}`], + ['an unclosed code point escape', `${BACKSLASH}u{41`], + ])('should leave an invalid escape, %s, as written', (_description, raw) => { + assert.strictEqual(replaceJavaScriptEscapes(`a${raw};`), `a${raw};`) + }) + + it('should keep reading escapes after an invalid one', () => { + assert.strictEqual(replaceJavaScriptEscapes('\\1\\"x'), '\\1 "x') + }) + + it('should return text with no backslash unchanged', () => { + const text = 'color: red; content: "é";' + + assert.strictEqual(replaceJavaScriptEscapes(text), text) + }) + + it('should keep a backslash at the very end of the text as written', () => { + assert.strictEqual(replaceJavaScriptEscapes('a\\'), 'a\\') + }) +}) + +describe('getTemplateCssText', () => { + function createCountingContext(text: string) { + const context = { + reads: 0, + get text() { + context.reads++ + return text + }, + } + return context + } + + it('should replace the escapes of a template context once, however often it is asked', () => { + const context = createCountingContext('content: \\"x\\";') + + assert.strictEqual(getTemplateCssText(context), 'content: "x ";') + assert.strictEqual(getTemplateCssText(context), 'content: "x ";') + assert.strictEqual(context.reads, 1) + }) + + it('should answer each template context from its own text', () => { + const first = createCountingContext('a\\;') + const second = createCountingContext('b\\;') + + assert.deepEqual([getTemplateCssText(first), getTemplateCssText(second)], ['a ;', 'b ;']) + }) +}) + +describe('getTemplateEscapeRuns', () => { + it.each([ + ['one run of adjacent escapes', ';\\x41\\x42b', [{ end: 9, start: 1 }]], + /** The padding moves in front of `co`, so the run covers the name before it too. */ + ['a run inside a name', 'co\\x6Cr', [{ end: 6, start: 0 }]], + ['two runs inside one name, as one', '\\x63ol\\x6Fr', [{ end: 10, start: 0 }]], + ['a hex escape stand-in', '&.bt\\x6E{', [{ end: 8, start: 4 }]], + [ + 'two runs apart', + '\\"x\\"', + [ + { end: 2, start: 0 }, + { end: 5, start: 3 }, + ], + ], + /** The stand-in `\72` takes the "r" after the run too, so the run ends after it. */ + ['a run whose stand-in takes the escaped character', 'u\\\\rl(', [{ end: 4, start: 1 }]], + /** The stand-in `\000041` covers the first run, the raw "4", and the run that continues the hex escape. */ + ['runs joined by one CSS hex escape', 'url(a\\\\4\\x31 b)', [{ end: 12, start: 5 }]], + /** Inside a url() argument the padding follows the cooked text, so the run covers itself alone. */ + ['a run inside a url() argument', 'url(co\\x6Cr)', [{ end: 10, start: 6 }]], + /** + * `äurl` is a different function name, so the run is no url content: the padding moves in front + * of `co`, and the run covers that name too. + */ + [ + 'a run after a function name that only ends in url', + 'äurl(co\\x6Cr)', + [{ end: 11, start: 5 }], + ], + ['an invalid escape, which stays as written', 'a\\1;', []], + ['no escape', 'color: red;', []], + ])('should record %s', (_description, text, expected) => { + assert.deepEqual(getTemplateEscapeRuns({ text }), expected) + }) +}) diff --git a/test/unit/plugin-configuration.test.ts b/test/unit/plugin-configuration.test.ts new file mode 100644 index 0000000..f29fc26 --- /dev/null +++ b/test/unit/plugin-configuration.test.ts @@ -0,0 +1,334 @@ +import { assert, describe, it } from 'vitest' + +import { PluginConfigurationManager } from '../../src/configuration/plugin-configuration' + +describe('PluginConfigurationManager', () => { + it('should expose the complete default tag list', () => { + const manager = new PluginConfigurationManager() + + assert.deepEqual(manager.config.tags, [ + 'styled', + 'css', + 'keyframes', + 'createGlobalStyle', + 'globalCss', + 'injectGlobal', + 'extend', + ]) + }) + + it('should merge lint settings while preserving defaults', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ lint: { unknownProperties: 'error' } }) + + assert.deepEqual(manager.config.lint, { + emptyRules: 'ignore', + unknownProperties: 'error', + }) + }) + + it('should reset omitted settings to their defaults on configuration changes', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ + emmet: { showAbbreviationSuggestions: false }, + tags: ['sty'], + validate: false, + }) + manager.updateFromPluginConfig({}) + + assert.deepEqual(manager.config.tags, [ + 'styled', + 'css', + 'keyframes', + 'createGlobalStyle', + 'globalCss', + 'injectGlobal', + 'extend', + ]) + assert.strictEqual(manager.config.validate, true) + assert.deepEqual(manager.config.emmet, {}) + }) + + it('should ignore malformed runtime configuration values and log each one', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ emmet: null, lint: [], tags: 5, validate: 'no' }, logger) + + assert.deepEqual(manager.config, new PluginConfigurationManager().config) + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting emmet: it accepts an object of Emmet options; received null. The default applies.', + 'Ignored the plugin setting lint: it accepts an object of lint settings; received []. The default applies.', + 'Ignored the plugin setting tags: it accepts a list of tag names, such as ["styled", "css"]; received 5. The default applies.', + 'Ignored the plugin setting validate: it accepts true or false; received "no". The default applies.', + ]) + }) + + it('should accept unambiguous shorthand values without logging', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig( + { + lint: { + unknownProperties: 'off', + validProperties: 'margin-vertical', + vendorPrefix: 'warn', + }, + tags: 'sty', + validate: 'false', + }, + logger, + ) + + assert.deepEqual(manager.config, { + emmet: {}, + lint: { + emptyRules: 'ignore', + unknownProperties: 'ignore', + validProperties: ['margin-vertical'], + vendorPrefix: 'warning', + }, + tags: ['sty'], + validate: false, + }) + assert.deepEqual(logger.messages, []) + }) + + it('should accept the string "true" for validate without logging', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ validate: false }) + manager.updateFromPluginConfig({ validate: 'true' }, logger) + + assert.strictEqual(manager.config.validate, true) + assert.deepEqual(logger.messages, []) + }) + + it('should drop an invalid lint level, keep the valid ones, and log the default that applies', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig( + { lint: { emptyRules: 'fatal', unknownProperties: 0, zeroUnits: 'error' } }, + logger, + ) + + assert.deepEqual(manager.config.lint, { emptyRules: 'ignore', zeroUnits: 'error' }) + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting lint.emptyRules: it accepts "ignore", "warning", or "error"; received "fatal". The plugin default, "ignore", applies.', + 'Ignored the plugin setting lint.unknownProperties: it accepts "ignore", "warning", or "error"; received 0. The CSS language service\'s own default for this rule applies.', + ]) + }) + + it('should accept a lint level or alias in any letter case without logging', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig( + { + lint: { + emptyRules: 'Error', + unknownProperties: 'OFF', + vendorPrefix: 'Warn', + zeroUnits: 'Warning', + }, + }, + logger, + ) + + assert.deepEqual(manager.config.lint, { + emptyRules: 'error', + unknownProperties: 'ignore', + vendorPrefix: 'warning', + zeroUnits: 'warning', + }) + assert.deepEqual(logger.messages, []) + }) + + it('should keep the string entries of lint.validProperties and log the rest', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig( + { lint: { validProperties: ['margin-vertical', 1, null] } }, + logger, + ) + + assert.deepEqual(manager.config.lint, { + emptyRules: 'ignore', + validProperties: ['margin-vertical'], + }) + assert.deepEqual(logger.messages, [ + 'Ignored part of the plugin setting lint.validProperties: it accepts a list of property names; received the entries 1 and null, which are not strings.', + ]) + }) + + it('should list three or more rejected lint.validProperties entries with an Oxford comma', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ lint: { validProperties: [1, 'gap', null, true] } }, logger) + + assert.deepEqual(manager.config.lint, { emptyRules: 'ignore', validProperties: ['gap'] }) + assert.deepEqual(logger.messages, [ + 'Ignored part of the plugin setting lint.validProperties: it accepts a list of property names; received the entries 1, null, and true, which are not strings.', + ]) + }) + + it('should keep the default Emmet options object when emmet is rejected', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ emmet: 'on' }) + + assert.strictEqual(manager.config.emmet, new PluginConfigurationManager().config.emmet) + }) + + it('should drop a lint.validProperties value that is neither a list nor a string and log it', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ lint: { validProperties: { a: 1 } } }, logger) + + assert.deepEqual(manager.config.lint, { emptyRules: 'ignore' }) + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting lint.validProperties: it accepts a list of property names, such as ["margin-vertical"]; received {"a":1}. The default applies.', + ]) + }) + + it('should reject a tag list holding a non-string entry and log it', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ tags: ['styled', 1] }, logger) + + assert.deepEqual(manager.config.tags, new PluginConfigurationManager().config.tags) + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting tags: it accepts a list of tag names, such as ["styled", "css"]; received ["styled",1]. The default applies.', + ]) + }) + + it('should log a top-level configuration that is not an object', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig('styled', logger) + + assert.deepEqual(manager.config, new PluginConfigurationManager().config) + assert.deepEqual(logger.messages, [ + 'Ignored the plugin configuration: it accepts an object of plugin settings; received "styled". The defaults apply.', + ]) + }) + + it('should describe a value that JSON cannot represent', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ validate: 1n }, logger) + + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting validate: it accepts true or false; received 1. The default applies.', + ]) + }) + + it.each([ + ['a function', () => true, '() => true'], + ['a symbol', Symbol('on'), 'Symbol(on)'], + ])( + 'should describe %s, which JSON leaves undefined, with String', + (_description, value, text) => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ validate: value }, logger) + + assert.deepEqual(logger.messages, [ + `Ignored the plugin setting validate: it accepts true or false; received ${text}. The default applies.`, + ]) + }, + ) + + it('should apply the configuration and never throw when the logger throws', () => { + const manager = new PluginConfigurationManager() + const throwingLogger = { + log() { + throw new Error('log failed') + }, + } + + assert.doesNotThrow(() => + manager.updateFromPluginConfig({ tags: 5, validate: false }, throwingLogger), + ) + assert.strictEqual(manager.config.validate, false) + }) + + it.each([null, undefined])( + 'should use defaults for an absent top-level configuration', + (config) => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig(config) + + assert.deepEqual(manager.config, new PluginConfigurationManager().config) + }, + ) + + it.each([ + ['a missing letter', { tag: ['sty'] }, 'tag', 'tags'], + ['a longer word', { validation: false }, 'validation', 'validate'], + ['different letter case', { Tags: ['sty'] }, 'Tags', 'tags'], + /** Case-sensitive, every letter differs; only a case-insensitive distance finds `tags`. */ + ['every letter in capitals', { TAGS: ['sty'] }, 'TAGS', 'tags'], + ['an extra letter', { lints: {} }, 'lints', 'lint'], + ])( + 'should ignore a misspelled setting name with %s, log it, and suggest the setting it resembles', + (_description, config, name, suggestion) => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig(config, logger) + + assert.deepEqual(manager.config, new PluginConfigurationManager().config) + assert.deepEqual(logger.messages, [ + `Ignored the plugin setting ${name}: the plugin has no setting with that name. Did you mean ${suggestion}? The plugin settings are emmet, lint, tags, and validate.`, + ]) + }, + ) + + it('should log an unknown setting name that resembles no setting without a suggestion', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig({ showErrors: true, validate: false }, logger) + + assert.strictEqual(manager.config.validate, false) + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting showErrors: the plugin has no setting with that name. The plugin settings are emmet, lint, tags, and validate.', + ]) + }) + + it('should not log the keys tsserver itself adds to a plugin configuration', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + manager.updateFromPluginConfig( + { global: true, name: '@styled/typescript-styled-plugin', tag: ['sty'] }, + logger, + ) + + assert.deepEqual(logger.messages, [ + 'Ignored the plugin setting tag: the plugin has no setting with that name. Did you mean tags? The plugin settings are emmet, lint, tags, and validate.', + ]) + }) + + it('should preserve unknown object settings from runtime configuration without logging', () => { + const manager = new PluginConfigurationManager() + const logger = createLogger() + const runtimeConfiguration: unknown = { + emmet: { futureOption: true }, + lint: { futureRule: 'off' }, + } + manager.updateFromPluginConfig(runtimeConfiguration, logger) + + assert.deepEqual(manager.config.lint, { + emptyRules: 'ignore', + futureRule: 'off', + } as unknown) + assert.deepEqual(manager.config.emmet, { futureOption: true } as unknown) + assert.deepEqual(logger.messages, []) + }) +}) + +function createLogger() { + const messages: string[] = [] + return { + log(message: string) { + messages.push(message) + }, + messages, + } +} diff --git a/test/unit/styled-virtual-document-provider.test.ts b/test/unit/styled-virtual-document-provider.test.ts new file mode 100644 index 0000000..20c398f --- /dev/null +++ b/test/unit/styled-virtual-document-provider.test.ts @@ -0,0 +1,1209 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { assert, describe, it } from 'vitest' + +import { PluginConfigurationManager } from '../../src/configuration/plugin-configuration' +import { StyledTemplateLanguageService } from '../../src/template-language-service' +import { + createCodeLookback, + createCssCodeScanState, + type CssCodeScanState, + INSIDE_NON_CODE, + nonCodeEnd, +} from '../../src/virtual-document/css-code-scanner' +import { + StyledVirtualDocumentProvider, + type VirtualDocumentProvider, +} from '../../src/virtual-document/styled-virtual-document-provider' +import { + createTemplateLineMap, + fromVirtualDocSpan, + fromVirtualDocSpanStrict, + resolveTemplatePosition, + resolveTemplatePositionStrict, + templateOffsetToPosition, + widenToEscapeRuns, +} from '../../src/virtual-document/template-line-map' +import { createTemplateContext } from './create-template-context' + +describe('StyledVirtualDocumentProvider', () => { + it('should wrap normal templates in a root rule and map positions in both directions', () => { + const context = createTemplateContext('color: red;\nmargin: 0;') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + + assert.strictEqual(document.getText(), ':root{\ncolor: red;\nmargin: 0;\n}') + assert.strictEqual(document.lineCount, 4) + assert.deepEqual(document.positionAt(':root{\n'.length), { line: 1, character: 0 }) + assert.strictEqual(document.offsetAt({ line: 1, character: 0 }), ':root{\n'.length) + assert.deepEqual(document.positionAt(document.getText().indexOf('margin')), { + line: 2, + character: 0, + }) + assert.strictEqual( + document.offsetAt({ line: 2, character: 0 }), + document.getText().indexOf('margin'), + ) + }) + + it.each([ + ['line separator', String.fromCharCode(0x2028)], + ['paragraph separator', String.fromCharCode(0x2029)], + ])('should map positions across the Unicode %s', (_description, separator) => { + const context = createTemplateContext(`color: red;${separator}margin: 0;`) + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + const sourceOffset = context.text.indexOf('margin') + const virtualOffset = provider.toVirtualDocOffset(sourceOffset, context) + const virtualPosition = provider.toVirtualDocPosition(context.toPosition(sourceOffset)) + + assert.deepEqual(document.positionAt(virtualOffset), virtualPosition) + assert.strictEqual(document.offsetAt(virtualPosition), virtualOffset) + }) + + it('should wrap keyframes templates in a keyframes rule', () => { + const context = createTemplateContext('0% { opacity: 0; }', 'keyframes') + const provider = new StyledVirtualDocumentProvider(ts) + + assert.strictEqual( + provider.createVirtualDocument(context).getText(), + '@keyframes custom {\n0% { opacity: 0; }\n}', + ) + }) + + it('should use a keyframes wrapper when the tag ends with keyframes', () => { + const context = createTemplateContext('0% { opacity: 0; }', 'styled.keyframes') + const provider = new StyledVirtualDocumentProvider(ts) + + assert.strictEqual( + provider.createVirtualDocument(context).getText(), + '@keyframes custom {\n0% { opacity: 0; }\n}', + ) + }) + + it('should only use the keyframes wrapper for the exact keyframes tag name', () => { + const context = createTemplateContext('0% { opacity: 0; }', 'kf') + const provider = new StyledVirtualDocumentProvider(ts) + + assert.strictEqual( + provider.createVirtualDocument(context).getText(), + ':root{\n0% { opacity: 0; }\n}', + ) + }) + + describe('value wrapper for value-shaped css fragments', () => { + it.each([ + ['a border value', '1px solid red'], + ['a value after a placeholder filled with whitespace', ' 1s linear'], + ['a multi-line value', '\n spin 2s linear infinite\n'], + ['a value with a colon inside parentheses', 'url(http://x/a.png) no-repeat'], + ['a value with a colon inside a string', '"a:b" 1px'], + ['a value with a colon inside a comment', '1px /* a: b; {} */ solid'], + ['a value with a colon inside a line comment', '1px solid // note: a; b\n'], + ['a number starting with a dot', '.5s ease'], + ['a single number', '2s'], + ['a value whose url() holds a comment with ";"', 'url(a/*;*/b.png) no-repeat'], + ['a value whose url() holds ";"', 'url(a;b.png) no-repeat'], + ['a value whose url() holds braces', 'url(a{b}.png) no-repeat'], + ])('should use the value wrapper for %s', (_description, text) => { + const context = createTemplateContext(text) + const provider = new StyledVirtualDocumentProvider(ts) + + assert.strictEqual(provider.getVirtualDocumentWrapper(context), ':root{all:\n') + assert.strictEqual( + provider.createVirtualDocument(context).getText(), + `:root{all:\n${text}\n}`, + ) + }) + + /** + * Each raw template here holds JavaScript escapes, so the document holds their stand-ins + * (replaceJavaScriptEscapes): a JavaScript `\\` is the CSS backslash that escapes what follows, + * written as a hex escape (`\22` for the quote) where that keeps the same length. + */ + it.each([ + [ + 'a value with a colon inside a string after a CSS-escaped quote', + '"a\\\\":b" 1px', + '"a\\22:b" 1px', + ], + ['a value with a CSS-escaped colon after its first word', 'a b\\\\:c', 'a b\\:c'], + ['a value whose JavaScript-escaped quotes hold a ";"', '\\"a;b\\" 1px', ' "a;b " 1px'], + ])('should use the value wrapper for %s', (_description, text, documentText) => { + const context = createTemplateContext(text) + const provider = new StyledVirtualDocumentProvider(ts) + + assert.strictEqual(provider.getVirtualDocumentWrapper(context), ':root{all:\n') + assert.strictEqual( + provider.createVirtualDocument(context).getText(), + `:root{all:\n${documentText}\n}`, + ) + }) + + it.each([ + ['a declaration without a semicolon', 'color: red'], + ['a declaration', 'color: red;'], + ['a rule', 'a { color: red; }'], + ['a stray closing brace', '1px }'], + ['a single identifier (a property name being typed)', '\n dis\n'], + ['a custom property name being typed', '--brand'], + ['a parent selector being typed', '& > a'], + ['a class selector being typed', '.active'], + ['an attribute selector being typed', '[disabled]'], + ['an at-rule being typed', '@media screen'], + /** The CSS-escaped quote opens no string, so the "{" after it is structural. */ + ['a rule whose selector holds a CSS-escaped quote', 'a\\\\"b { color: red; }'], + /** The CSS-escaped "/" opens no comment, so the "{" after it is structural. */ + ['a rule whose selector holds a CSS-escaped "/" before "*"', 'a\\\\/*b { color: red; }'], + ['a single identifier starting with a CSS escape', '\\\\:b'], + ['a single identifier starting with a hex escape', '\\\\31 0'], + ['only whitespace', ' '], + ['an empty template', ''], + ])('should keep the declaration wrapper for %s', (_description, text) => { + const context = createTemplateContext(text) + + assert.strictEqual( + new StyledVirtualDocumentProvider(ts).getVirtualDocumentWrapper(context), + ':root{\n', + ) + }) + + it.each(['styled.div', 'createGlobalStyle', 'kf'])( + 'should keep the declaration wrapper for a value-shaped %s template', + (tagName) => { + const context = createTemplateContext('1px solid red', tagName) + + assert.strictEqual( + new StyledVirtualDocumentProvider(ts).getVirtualDocumentWrapper(context), + ':root{\n', + ) + }, + ) + + it('should use the value wrapper for a dotted tag ending in css', () => { + const context = createTemplateContext('1px solid red', 'styled.css') + + assert.strictEqual( + new StyledVirtualDocumentProvider(ts).getVirtualDocumentWrapper(context), + ':root{all:\n', + ) + }) + + it('should map positions through the longer value wrapper and clamp the closing wrapper to the template end', () => { + const context = createTemplateContext('1px solid red') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + const lineMap = createTemplateLineMap(context) + const wrapperLength = ':root{all:\n'.length + + assert.strictEqual(provider.toVirtualDocOffset(4, context), wrapperLength + 4) + assert.strictEqual(provider.fromVirtualDocOffset(wrapperLength + 4, context), 4) + assert.deepEqual(document.positionAt(wrapperLength + 4), { line: 1, character: 4 }) + assert.deepEqual(resolveTemplatePosition(provider, { line: 2, character: 0 }, lineMap), { + line: 0, + character: context.text.length, + }) + }) + }) + + it('should only map the template body back from the virtual document', () => { + const context = createTemplateContext('color: red;') + const lineMap = createTemplateLineMap(context) + const provider = new StyledVirtualDocumentProvider(ts) + const wrapperLength = provider.getVirtualDocumentWrapper(context).length + + assert.strictEqual(provider.fromVirtualDocOffset(wrapperLength - 1, context), -1) + assert.strictEqual(provider.fromVirtualDocOffset(wrapperLength, context), 0) + assert.strictEqual( + provider.fromVirtualDocOffset(wrapperLength + context.text.length, context), + context.text.length, + ) + assert.strictEqual( + provider.fromVirtualDocOffset(wrapperLength + context.text.length + 1, context), + context.text.length + 1, + ) + /** + * Virtual line 0 is the wrapper's own line: it has no source counterpart, so it is rejected + * rather than mapped to a position; only the closing wrapper clamps (the last assertion). + */ + assert.strictEqual( + resolveTemplatePosition(provider, { line: 0, character: 0 }, lineMap), + undefined, + ) + assert.deepEqual(resolveTemplatePosition(provider, { line: 1, character: 0 }, lineMap), { + line: 0, + character: 0, + }) + assert.deepEqual(provider.fromVirtualDocPosition({ line: 0, character: 0 }), { + line: -1, + character: 0, + }) + /** + * Virtual line 2 does not exist in this single-line template (the trailing "\n}" wrapper + * line): it clamps to the template end instead of being dropped. + */ + assert.deepEqual(resolveTemplatePosition(provider, { line: 2, character: 0 }, lineMap), { + line: 0, + character: 'color: red;'.length, + }) + }) + + it('should validate positions returned by a third-party provider that implements only fromVirtualDocPosition', () => { + const context = createTemplateContext('color: red;') + const lineMap = createTemplateLineMap(context) + const provider = { + fromVirtualDocPosition(position: ts.LineAndCharacter) { + return { line: position.line - 1, character: position.character } + }, + } + + assert.strictEqual( + resolveTemplatePosition(provider, { line: 0, character: 0 }, lineMap), + undefined, + ) + assert.deepEqual(resolveTemplatePosition(provider, { line: 1, character: 0 }, lineMap), { + line: 0, + character: 0, + }) + }) + + it('should reject rather than clamp a position outside the template for code-fix edits', () => { + const context = createTemplateContext('color: red;') + const lineMap = createTemplateLineMap(context) + const provider = new StyledVirtualDocumentProvider(ts) + + /** Inside the wrapper prefix (virtual line 0): no source counterpart, must be rejected. */ + assert.strictEqual( + resolveTemplatePositionStrict(provider, { line: 0, character: 0 }, lineMap), + undefined, + ) + /** + * Past the template end (the trailing "\n}" wrapper line): must be rejected, not clamped, + * since applying an edit there would rewrite text outside the template. + */ + assert.strictEqual( + resolveTemplatePositionStrict(provider, { line: 2, character: 0 }, lineMap), + undefined, + ) + /** A genuinely in-range position still resolves normally. */ + assert.deepEqual(resolveTemplatePositionStrict(provider, { line: 1, character: 3 }, lineMap), { + line: 0, + character: 3, + }) + }) + + describe('escape runs', () => { + /** `\x63` (0 to 4) and `\x72` (9 to 13) are the escape runs; `olr: ` sits between them. */ + const text = '\\x63olr: \\x72ed;' + const provider = new StyledVirtualDocumentProvider(ts) + const at = (character: number) => ({ line: 1, character }) + + it('should record each escape run in the line map', () => { + assert.deepEqual(createTemplateLineMap(createTemplateContext(text)).escapeRuns, [ + { end: 4, start: 0 }, + { end: 13, start: 9 }, + ]) + }) + + it.each([ + ['a range starting inside a run', 2, 6, 0, 6], + ['a range ending inside a run', 5, 11, 5, 13], + ['a range with both ends inside runs', 1, 11, 0, 13], + ['a range touching runs only at their edges', 4, 9, 4, 9], + ['an empty range inside a run', 2, 2, 0, 4], + ])( + 'should widen %s to whole runs', + (_description, startCharacter, endCharacter, expectedStart, expectedEnd) => { + const lineMap = createTemplateLineMap(createTemplateContext(text)) + + assert.deepEqual( + fromVirtualDocSpan( + provider, + { end: at(endCharacter), start: at(startCharacter) }, + lineMap, + ), + { end: expectedEnd, start: expectedStart }, + ) + }, + ) + + it.each([ + ['a range overlapping a run', 3, 6, false], + ['an empty range inside a run', 2, 2, false], + ['a range between runs, touching their edges', 4, 9, true], + ['an empty range at the start of a run', 9, 9, true], + ])( + 'should keep a code-fix edit only when it leaves every run whole: %s', + (_description, startCharacter, endCharacter, isKept) => { + const lineMap = createTemplateLineMap(createTemplateContext(text)) + + assert.deepEqual( + fromVirtualDocSpanStrict( + provider, + { end: at(endCharacter), start: at(startCharacter) }, + lineMap, + ), + isKept ? { end: endCharacter, start: startCharacter } : undefined, + ) + }, + ) + + it.each([ + ['an empty span inside a run', 2, 2, { end: 4, start: 0 }], + ['a span from inside one run to inside the next', 3, 10, { end: 13, start: 0 }], + ['a span touching runs only at their edges', 4, 9, { end: 9, start: 4 }], + ['a span after every run', 14, 16, { end: 16, start: 14 }], + ['a span at the text start', 0, 0, { end: 0, start: 0 }], + ])('should widen %s by offset', (_description, start, end, expected) => { + const { escapeRuns } = createTemplateLineMap(createTemplateContext(text)) + + assert.deepEqual(widenToEscapeRuns(start, end, escapeRuns), expected) + }) + + it('should leave a span unchanged when the template has no escape runs', () => { + assert.deepEqual(widenToEscapeRuns(2, 5, []), { end: 5, start: 2 }) + }) + }) + + /** + * Every offset the span helpers return round-trips through templateOffsetToPosition and the + * context's own toOffset (TypeScript's line map), an instrument independent of the plugin's own + * line-start table. + */ + describe('fromVirtualDocSpan and fromVirtualDocSpanStrict', () => { + const separator = String.fromCharCode(0x2028) + const paragraph = String.fromCharCode(0x2029) + const texts: Array<[string, string]> = [ + ['CRLF', 'a {\r\n color: red;\r\n}'], + ['a lone CR', 'a {\r color: red;\r}'], + ['U+2028 and U+2029', `color: red;${separator}margin: 0;${paragraph}top: 0;`], + ['escapes over lines', 'co\\x6Cr: red;\n\\x63olr: blue;'], + ] + + it.each(texts)( + 'should map every range over %s to offsets that round-trip independently', + (_description, text) => { + const context = createTemplateContext(text) + const provider = new StyledVirtualDocumentProvider(ts) + const lineMap = createTemplateLineMap(context) + const document = provider.createVirtualDocument(context) + const roundTrippedOffsetsOf = (span: { end: number; start: number }) => ({ + end: context.toOffset(templateOffsetToPosition(span.end, lineMap)), + start: context.toOffset(templateOffsetToPosition(span.start, lineMap)), + }) + let checked = 0 + for (let start = ':root{\n'.length; start <= document.getText().length; start++) { + for (const length of [0, 1, 3]) { + const virtualRange = { + end: document.positionAt(start + length), + start: document.positionAt(start), + } + const span = fromVirtualDocSpan(provider, virtualRange, lineMap) + const strictSpan = fromVirtualDocSpanStrict(provider, virtualRange, lineMap) + + if (span) { + assert.deepEqual(roundTrippedOffsetsOf(span), span) + } + if (strictSpan) { + assert.deepEqual(roundTrippedOffsetsOf(strictSpan), strictSpan) + } + checked += span ? 1 : 0 + } + } + assert.isAbove(checked, text.length) + }, + ) + + it('should reject a span inside the opening wrapper, and a strict span past the template end', () => { + const context = createTemplateContext('color: red;') + const provider = new StyledVirtualDocumentProvider(ts) + const lineMap = createTemplateLineMap(context) + const wrapperLine = { end: { line: 0, character: 3 }, start: { line: 0, character: 0 } } + const closingLine = { end: { line: 2, character: 1 }, start: { line: 2, character: 0 } } + + assert.isUndefined(fromVirtualDocSpan(provider, wrapperLine, lineMap)) + assert.isUndefined(fromVirtualDocSpanStrict(provider, wrapperLine, lineMap)) + assert.deepEqual(fromVirtualDocSpan(provider, closingLine, lineMap), { end: 11, start: 11 }) + assert.isUndefined(fromVirtualDocSpanStrict(provider, closingLine, lineMap)) + }) + }) + + it('should keep one line map per template context', () => { + const context = createTemplateContext('color: red;\nmargin: 0;') + + assert.strictEqual(createTemplateLineMap(context), createTemplateLineMap(context)) + assert.notStrictEqual(createTemplateLineMap({ ...context }), createTemplateLineMap(context)) + }) + + it('should read the substituted text for escape runs only when the raw text holds a backslash', () => { + const readsOf = (rawText: string) => { + let reads = 0 + const base = createTemplateContext(rawText, 'styled.div') + const context = { + rawText, + get text() { + reads++ + return base.text + }, + } + const lineMap = createTemplateLineMap(context) + const afterBuild = reads + return { afterBuild, escapeRuns: lineMap.escapeRuns, reads } + } + + assert.deepEqual(readsOf('color: red;'), { afterBuild: 0, escapeRuns: [], reads: 0 }) + assert.deepEqual(readsOf('co\\x6Cr: red;'), { + afterBuild: 0, + /** The name characters before the run join it; the `r` after it does not. */ + escapeRuns: [{ end: 'co\\x6C'.length, start: 0 }], + reads: 1, + }) + }) + + it('should reuse the last reading only for the same tag kind and raw text', () => { + const provider = new StyledVirtualDocumentProvider(ts) + const wrapperOf = (text: string, tagName: string) => + provider.getVirtualDocumentWrapper(createTemplateContext(text, tagName)) + + assert.deepEqual( + [ + wrapperOf('1px solid red', 'css'), + wrapperOf('1px solid red', 'css'), + wrapperOf('1px solid red', 'styled.div'), + wrapperOf('1px solid red', 'css'), + wrapperOf('color: red;', 'css'), + wrapperOf('color: red;', 'keyframes'), + ], + [ + ':root{all:\n', + ':root{all:\n', + ':root{\n', + ':root{all:\n', + ':root{\n', + '@keyframes custom {\n', + ], + ) + }) + + describe('createValueReadingDocument', () => { + it('should build the value reading of a single-identifier css template', () => { + const context = createTemplateContext('\n colr\n') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createValueReadingDocument(context) + + assert.strictEqual(document?.getText(), ':root{all:\n\n colr\n\n}') + assert.deepEqual(document?.positionAt(':root{all:\n\n '.length), { line: 2, character: 2 }) + assert.strictEqual(provider.createVirtualDocument(context).getText(), ':root{\n\n colr\n\n}') + }) + + it.each([ + ['a css declaration', 'color: red;', 'css'], + ['a value-shaped css template', '1px solid red', 'css'], + ['a single identifier in a component', 'colr', 'styled.div'], + ['a single identifier in keyframes', 'colr', 'keyframes'], + ])('should build no value reading for %s', (_description, text, tagName) => { + const provider = new StyledVirtualDocumentProvider(ts) + + assert.isUndefined(provider.createValueReadingDocument(createTemplateContext(text, tagName))) + }) + }) + + it('should compute the line map from context.rawText alone, never through context.toPosition/toOffset', () => { + /** + * createTemplateLineMap is the only place this mapping direction reads a TemplateContext at + * all: resolveTemplatePosition/resolveTemplatePositionStrict take the resulting + * TemplateLineMap, a plain value, not a context, so they cannot reach back into one even if a + * regression tried. A context whose toPosition/toOffset throw still produces a usable line + * map, and that line map still resolves positions correctly, proving createTemplateLineMap + * itself never calls them either. This is what keeps a cached virtual document (reused across + * requests by canReuseVirtualDocument because the file name, raw text, and reading still + * match) correct after an edit elsewhere in the file moves + * the template: the previous request's TemplateContext.toPosition/toOffset answer against + * the template's old position (StandardTemplateContext memoizes it, typescript-template- + * language-service-decorator), so a cached line map that still consulted them would map to + * the wrong span. + */ + const throwingContext: TemplateContext = { + ...createTemplateContext('color: red;'), + toPosition() { + throw new Error('Debug Failure. Bad line number.') + }, + toOffset() { + throw new Error('Debug Failure. Bad line number.') + }, + } + const provider: Pick = { + fromVirtualDocPosition: (position) => ({ + line: position.line - 1, + character: position.character, + }), + } + + const lineMap = createTemplateLineMap(throwingContext) + + assert.deepEqual(resolveTemplatePosition(provider, { line: 1, character: 3 }, lineMap), { + line: 0, + character: 3, + }) + assert.deepEqual(resolveTemplatePositionStrict(provider, { line: 1, character: 3 }, lineMap), { + line: 0, + character: 3, + }) + /** + * The opening wrapper (virtual line 0) still has no source counterpart and rejects, exactly + * as it does for a well-behaved context. + */ + assert.strictEqual( + resolveTemplatePosition(provider, { line: 0, character: 0 }, lineMap), + undefined, + ) + assert.strictEqual( + resolveTemplatePositionStrict(provider, { line: 0, character: 0 }, lineMap), + undefined, + ) + }) + + it('should preserve a Unicode line separator verbatim inside a quoted string value', () => { + /** + * A LINE SEPARATOR inside a CSS string value is real string content, not a line break in + * the template: normalizing it to a real newline breaks the string out of its quotes and + * reports a spurious syntax error instead of the real one later in the rule. + */ + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext('content: "a' + separator + 'b"; colr: red;') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + + assert.strictEqual(document.getText(), ':root{\ncontent: "a' + separator + 'b"; colr: red;\n}') + }) + + it('should not treat an apostrophe inside a CSS comment as opening a string', () => { + /** + * A quote inside a CSS comment is not a string: CSS comments are not string values, so an + * unmatched apostrophe there (an English contraction, most commonly) must not disable + * separator normalization for the rest of the template, matching CSS tokenization, where + * comments and strings are recognized independently of each other. + */ + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext(`/* don't */\ncolor: red;${separator}boarder: 1px;`) + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + + assert.strictEqual(document.getText(), ":root{\n/* don't */\ncolor: red;\nboarder: 1px;\n}") + }) + + it('should not treat an apostrophe inside a "//" line comment as opening a string', () => { + /** + * SCSS (unlike plain CSS) also has "//" line comments. A quote inside one is not a string + * (matching the block-comment case above), so an unmatched apostrophe there must not disable + * separator normalization for the rest of the template: the comment ends at the real line + * break the separator becomes, exactly as it does without the apostrophe. + */ + const separator = String.fromCharCode(0x2028) + const withApostrophe = createTemplateContext(`// don't${separator}boarder: 1px;`) + const withoutApostrophe = createTemplateContext(`// dont${separator}boarder: 1px;`) + const provider = new StyledVirtualDocumentProvider(ts) + + assert.strictEqual( + provider.createVirtualDocument(withApostrophe).getText(), + ":root{\n// don't\nboarder: 1px;\n}", + ) + assert.strictEqual( + provider.createVirtualDocument(withoutApostrophe).getText(), + ':root{\n// dont\nboarder: 1px;\n}', + ) + }) + + it('should not treat "//" inside an unquoted url() as a line comment', () => { + /** + * The CSS url() function lexes its unquoted argument as a single token, not general code: + * "//" there is ordinary URL content, not a comment start. A line-comment scanner blind to + * this would swallow everything after it up to the next real line break, including + * "boarder: 1px;" here. + */ + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext(`background: url(http://x);${separator}boarder: 1px;`) + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + + assert.strictEqual(document.getText(), ':root{\nbackground: url(http://x);\nboarder: 1px;\n}') + }) + + it('should end an unterminated string at a real line break, matching CSS tokenization', () => { + /** + * A CSS string never spans a real line break unless the newline itself is escaped: an + * opening quote with no closing quote before the next "\n" is an unterminated string, so + * normalization must resume there rather than staying "inside a string" (and therefore + * un-normalized) for the rest of the template. + */ + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext( + `content: "unterminated;\ncolor: red;${separator}boarder: 1px;`, + ) + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + + assert.strictEqual( + document.getText(), + ':root{\ncontent: "unterminated;\ncolor: red;\nboarder: 1px;\n}', + ) + }) + + it('should keep a string open past a CSS-escaped quote, matching CSS tokenization', () => { + /** + * The raw `\\` is the CSS backslash that escapes the quote after it, so the first separator + * here sits inside the string and stays verbatim, while the second, after the real closing + * quote, becomes "\n". + */ + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext(`content: "a\\\\"${separator}b";${separator}color: red;`) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\ncontent: "a \\"${separator}b";\ncolor: red;\n}`) + }) + + it('should read a JavaScript-escaped quote as the quote styled-components receives, which opens a string', () => { + /** The separator inside the string stays verbatim; the one after it becomes "\n". */ + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext(`content: \\"a${separator}b\\";${separator}color: red;`) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\ncontent: "a${separator}b ";\ncolor: red;\n}`) + }) + + it('should keep positions mapped through the source line map after JavaScript escapes', () => { + const context = createTemplateContext('content: \\"x\\" \\n;\ncolr: red;') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + const sourceOffset = context.rawText.indexOf('colr') + const virtualOffset = provider.toVirtualDocOffset(sourceOffset, context) + + assert.strictEqual(document.getText(), ':root{\ncontent: "x " ;\ncolr: red;\n}') + assert.deepEqual(document.positionAt(virtualOffset), { line: 2, character: 0 }) + assert.strictEqual(document.offsetAt({ line: 2, character: 0 }), virtualOffset) + }) + + describe('nested block @layer rewrite', () => { + /** The expected same-length rewrite: spaces over the prelude, "&" on its last character. */ + function rewritten(prelude: string): string { + return ' '.repeat(prelude.length - 1) + '&' + } + + it.each([ + ['a named layer', '@layer u ', '{ color: red; }'], + ['a dotted layer name', '@layer framework.base ', '{ color: red; }'], + ['an anonymous layer', '@layer ', '{ color: red; }'], + ['an anonymous layer with no space before its body', '@layer', '{ color: red; }'], + ['a name followed directly by its body', '@layer u', '{ color: red; }'], + ['a block comment before the name', '@layer /* c */ u ', '{ color: red; }'], + ['a block comment right after the keyword', '@layer/* c */u ', '{ color: red; }'], + ['a block comment holding a brace after the name', '@layer u /* { */ ', '{ color: red; }'], + ['an anonymous layer holding only a comment', '@layer /* c */ ', '{ color: red; }'], + ['a keyword in capitals', '@LAYER u ', '{ color: red; }'], + ['a keyword in mixed case', '@Layer u ', '{ color: red; }'], + /** The CSS scanner reads every code unit from U+0080 as a name character, U+00A0 included. */ + ['a name holding a non-breaking space', '@layer a b ', '{ color: red; }'], + ])('should rewrite %s at a component template top level', (_description, prelude, body) => { + const context = createTemplateContext(`${prelude}${body}`, 'styled.div') + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\n${rewritten(prelude)}${body}\n}`) + assert.strictEqual(document.getText().length, ':root{\n'.length + context.text.length + 2) + }) + + it('should rewrite a nested layer after a url() holding "}", which closes no block', () => { + const prefix = 'body { background: url(a}b.png); ' + const context = createTemplateContext( + `${prefix}@layer u { color: red; } }`, + 'createGlobalStyle', + ) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual( + document.getText(), + `:root{\n${prefix}${rewritten('@layer u ')}{ color: red; } }\n}`, + ) + }) + + it('should rewrite a layer after a CSS-escaped quote, which opens no string', () => { + const context = createTemplateContext( + 'content: \\\\"a; @layer u { color: red; }', + 'styled.div', + ) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual( + document.getText(), + `:root{\ncontent: \\"a; ${rewritten('@layer u ')}{ color: red; }\n}`, + ) + }) + + it('should not count a CSS-escaped "{" as opening a block of a global-style template', () => { + /** The layer stays at the stylesheet top level, where the parser reads a block `@layer` correctly. */ + const context = createTemplateContext( + '.a\\\\{ @layer u { color: red; } }', + 'createGlobalStyle', + ) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\n .a\\{ @layer u { color: red; } }\n}`) + }) + + it('should leave a CSS-escaped "@", which makes the keyword part of an identifier, untouched', () => { + const context = createTemplateContext('\\\\@layer u { color: red; }', 'styled.div') + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\n\\40layer u { color: red; }\n}`) + }) + + it('should rewrite a layer whose "@" is JavaScript-escaped, since styled-components receives a real "@"', () => { + const context = createTemplateContext('\\@layer u { color: red; }', 'styled.div') + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\n ${rewritten('@layer u ')}{ color: red; }\n}`) + }) + + it('should rewrite a prelude holding a line comment, keeping the line break that ends it', () => { + const context = createTemplateContext('@layer // c\n u { color: red; }', 'styled.div') + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual( + document.getText(), + `:root{\n${' '.repeat('@layer // c'.length)}\n${rewritten(' u ')}{ color: red; }\n}`, + ) + }) + + it('should rewrite a layer nested inside a rule, keeping the prelude line break', () => { + const context = createTemplateContext( + '&:hover {\n @layer u\n { color: red; }\n}', + 'styled.div', + ) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + /** The prelude runs from "@" to the "{" on the next line; its line break stays put. */ + assert.strictEqual( + document.getText(), + `:root{\n&:hover {\n ${' '.repeat('@layer u'.length)}\n${rewritten(' ')}{ color: red; }\n}\n}`, + ) + }) + + it('should rewrite a layer inside a rule of a global-style template but not at its top level', () => { + const text = '@layer base { body { color: red; } }\nbody { @layer u { color: red; } }' + const context = createTemplateContext(text, 'createGlobalStyle') + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual( + document.getText(), + `:root{\n${text.replace('@layer u ', rewritten('@layer u '))}\n}`, + ) + }) + + it.each([ + ['the statement form', '@layer a, b;\ncolor: red;'], + ['a list prelude', '@layer a, b { color: red; }'], + ['two names separated by a comment', '@layer a /* c */ b { color: red; }'], + ['an unterminated comment in the prelude', '@layer u /* { color: red; }'], + ['a lone "/" in the prelude', '@layer u / { color: red; }'], + ['a longer at-keyword', '@layers u { color: red; }'], + ['the keyword inside a string', 'content: "@layer u {";'], + ['the keyword inside a comment', '/* @layer u { */ color: red;'], + ])('should leave %s untouched', (_description, text) => { + const context = createTemplateContext(text, 'styled.div') + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual(document.getText(), `:root{\n${text}\n}`) + }) + + it('should rewrite the prelude and normalize a Unicode line separator in the same pass', () => { + const separator = String.fromCharCode(0x2028) + const context = createTemplateContext( + `color: red;${separator}@layer u { color: red; }`, + 'styled.div', + ) + const document = new StyledVirtualDocumentProvider(ts).createVirtualDocument(context) + + assert.strictEqual( + document.getText(), + `:root{\ncolor: red;\n${rewritten('@layer u ')}{ color: red; }\n}`, + ) + }) + }) + + it('should map positions across a multi-line template using the source line map', () => { + const context = createTemplateContext('a {\n color: red;\n}\ncolr: blue;') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + const sourceOffset = context.rawText.indexOf('colr') + const virtualOffset = provider.toVirtualDocOffset(sourceOffset, context) + const virtualPosition = provider.toVirtualDocPosition(context.toPosition(sourceOffset)) + + assert.deepEqual(document.positionAt(virtualOffset), virtualPosition) + assert.strictEqual(document.offsetAt(virtualPosition), virtualOffset) + }) + + it('should reuse a cached document only when the raw (unsubstituted) text matches', () => { + const provider = new StyledVirtualDocumentProvider(ts) + const previousContext = createTemplateContext('color: red;') + const identicalContext = createTemplateContext('color: red;') + const differentContext = createTemplateContext('color: blue;') + + assert.isTrue(provider.canReuseVirtualDocument(previousContext, identicalContext)) + assert.isFalse(provider.canReuseVirtualDocument(previousContext, differentContext)) + + /** + * Two contexts whose substituted text happens to be identical (equal-length, same-shape + * interpolations) but whose raw text differs must not be treated as reusable, since their + * relative line maps are not guaranteed to match. + */ + const rawA = createTemplateContext('color: ${aa};\ncolr: blue;') + const rawB: TemplateContext = { + ...createTemplateContext('color: ${bb};\ncolr: blue;'), + get text() { + return rawA.text + }, + } + assert.notStrictEqual(rawA.rawText, rawB.rawText) + assert.strictEqual(rawA.text, rawB.text) + assert.isFalse(provider.canReuseVirtualDocument(rawA, rawB)) + }) + + it.each([ + [ + 'a nested and a global-style top level', + '@layer x { color: red; }', + 'styled.div', + 'createGlobalStyle', + ], + ['a single-identifier css fragment and a component', 'colr', 'css', 'styled.div'], + ])( + 'should not reuse a document across tags that read the same text differently: %s', + (_description, text, firstTag, secondTag) => { + const provider = new StyledVirtualDocumentProvider(ts) + const first = createTemplateContext(text, firstTag) + const second = createTemplateContext(text, secondTag) + + /** Same wrapper, so only the rest of the reading tells the two apart. */ + assert.strictEqual( + provider.getVirtualDocumentWrapper(first), + provider.getVirtualDocumentWrapper(second), + ) + assert.isFalse(provider.canReuseVirtualDocument(first, second)) + assert.isFalse(provider.canReuseVirtualDocument(second, first)) + assert.isTrue(provider.canReuseVirtualDocument(first, createTemplateContext(text, firstTag))) + }, + ) + + describe('diagnostics for the same text under tags that read it differently', () => { + const cases: Array<[string, string, Array<[string, string[]]>]> = [ + [ + 'a top-level block @layer', + '@layer x { color: red; }', + [ + ['styled.div', []], + ['createGlobalStyle', ['{ expected']], + ], + ], + [ + 'a single identifier', + 'colr', + [ + ['css', []], + ['styled.div', ['semi-colon expected', 'colon expected']], + ], + ], + ] + + it.each( + cases.flatMap(([description, text, readings]) => [ + [`${description}, in order`, text, readings], + [`${description}, reversed`, text, readings.toReversed()], + ]), + )('should report each reading its own diagnostics: %s', (_description, text, readings) => { + const service = new StyledTemplateLanguageService( + ts, + new PluginConfigurationManager(), + new StyledVirtualDocumentProvider(ts), + ) + const messages = (tagName: string) => + service + .getSemanticDiagnostics(createTemplateContext(text, tagName)) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) + + assert.deepEqual( + readings.map(([tagName]) => [tagName, messages(tagName)]), + readings, + ) + }) + }) + + it('should keep mapping correctly through a reused document even when the context that built it has a stale toPosition/toOffset', () => { + /** + * Reproduces the reported defect: a request that inserts a line above the template leaves + * StandardTemplateContext.toPosition/toOffset (typescript-template-language-service- + * decorator) memoized against the template's old file position, but + * canReuseVirtualDocument still says the cached document (same fileName, rawText, wrapper) + * is reusable, since none of those three depend on where the template sits in the file. A + * document built from a context whose toPosition/toOffset are wrong by a fixed offset (the + * shape a "line inserted above" edit produces) must still map positions correctly: the + * document's own positionAt/offsetAt never consult the context that built it once creation + * finishes. + */ + const context = createTemplateContext('color: red;\nmargin: 0;') + const staleContext: TemplateContext = { + ...context, + toPosition: (offset) => { + const real = context.toPosition(offset) + return { line: real.line + 5, character: real.character } + }, + toOffset: (position) => context.toOffset({ ...position, line: position.line - 5 }), + } + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(staleContext) + + const sourceOffset = context.rawText.indexOf('margin') + const virtualOffset = provider.toVirtualDocOffset(sourceOffset, context) + const correctVirtualPosition = provider.toVirtualDocPosition(context.toPosition(sourceOffset)) + + assert.deepEqual(document.positionAt(virtualOffset), correctVirtualPosition) + assert.strictEqual(document.offsetAt(correctVirtualPosition), virtualOffset) + }) + + it('should not start a new line at a form feed, which ends a CSS string but is no line terminator for position mapping', () => { + const context = createTemplateContext('color: red;\fmargin: 0;') + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + const virtualOffset = provider.toVirtualDocOffset(context.text.indexOf('margin'), context) + + assert.deepEqual(document.positionAt(virtualOffset), { line: 1, character: 12 }) + assert.strictEqual(document.offsetAt({ line: 1, character: 12 }), virtualOffset) + }) +}) + +describe('nonCodeEnd', () => { + const lineSeparator = String.fromCharCode(0x2028) + + it.each([ + ['an unterminated block comment at the end of the text', 'a /* b', 2, 6], + ['an unterminated string at the end of the text', 'a "bc', 2, 5], + ['a string whose last character is a lone backslash', 'a "b\\', 2, 5], + /** The comment's own terminator (offset 4) is included, so code resumes at "b" (5). */ + ['a "//" line comment ending at U+2028', `// a${lineSeparator}b`, 0, 5], + ['a "//" line comment ending at a form feed', '// a\fb', 0, 5], + /** The escape covers "\r" (3) and "\n" (4), so the string runs through its closing quote (6). */ + ['a string holding an escaped CRLF', '"a\\\r\nb"c', 0, 7], + /** Six hex digits (2 through 7), then the escape takes the LF at 8; the quote at 10 closes. */ + ['a string holding a six-digit hex escape followed by LF', '"\\00004a\nb"c', 0, 11], + /** A seventh hex digit is ordinary content; the LF after it ends the string (at 9). */ + ['a string whose hex escape stops after six digits', '"\\00004aa\nb"', 0, 9], + ['a string ended by a form feed', '"a\fb"', 0, 2], + ['a string ended by an unescaped CR', '"a\rb"', 0, 2], + ])('should end %s where the CSS scanner does', (_description, text, index, expectedEnd) => { + assert.strictEqual(nonCodeEnd(text, index, createCssCodeScanState()), expectedEnd) + }) + + it('should return -1 at a code position', () => { + assert.strictEqual(nonCodeEnd('a / b', 2, createCssCodeScanState()), -1) + }) + + it.each([ + /** The escape covers the quote (1, 2), and the identifier runs on through "x" (3). */ + ['an escaped quote, opening no string', '\\"x; }', 0, 3], + /** The escape covers the first "/" (1, 2); the second "/" is code, so no comment opens. */ + ['an escaped "/", opening no comment', 'a\\//b', 1, 3], + ['an escaped "/" before "*", opening no comment', 'a\\/* } */', 1, 3], + ['an escaped "{"', 'a\\{ b', 1, 3], + /** The escape's trailing space (3) belongs to it, so the identifier continues through "url" (4 through 6). */ + ['a hex escape followed by a space and more name characters', '\\41 url(//x)', 0, 7], + ['an escaped backslash', '\\\\"a"', 0, 2], + ])('should step over %s as one identifier run', (_description, text, index, expectedEnd) => { + const state = createCssCodeScanState() + + assert.strictEqual(nonCodeEnd(text, index, state), expectedEnd) + assert.isUndefined(state.url) + }) + + it.each([ + ['before a line feed', 'a\\\nb', 1], + ['before a form feed', 'a\\\fb', 1], + ['before a Unicode line separator', `a\\${lineSeparator}b`, 1], + /** The virtual document follows the template with its closing "\n}", so nothing is escaped. */ + ['at the end of the text', 'a\\', 1], + ])('should read a backslash %s as plain code', (_description, text, index) => { + assert.strictEqual(nonCodeEnd(text, index, createCssCodeScanState()), -1) + }) + + it.each([ + ['url(', 'url(//x)', 0, -1], + ['URL(', 'URL(//x)', 0, -1], + ['url-prefix(', 'url-prefix(//x)', 0, -1], + ['an escaped "u" of url(', '\\url(//x)', 0, 4], + ['an escaped "r" of url(', 'u\\rl(//x)', 0, -1], + ['a hex-escaped "u" of url(', '\\75 rl(//x)', 0, 6], + ['url( after a string', '"a"url(//x)', 3, -1], + ])( + 'should open an unquoted url() argument at %s, as the CSS parser does', + (_description, text, index, expectedEnd) => { + const state = createCssCodeScanState() + + assert.strictEqual(nonCodeEnd(text, index, state), expectedEnd) + assert.isDefined(state.url) + }, + ) + + it.each([ + ['a longer function name', 'image-url(//x)', 6], + ['a name ending in url', 'myurl(//x)', 2], + ['a hash', '#url(//x)', 1], + ['url after a space', 'url (//x)', 0], + ])('should open no url() argument at %s', (_description, text, index) => { + const state = createCssCodeScanState() + + assert.strictEqual(nonCodeEnd(text, index, state), -1) + assert.isUndefined(state.url) + }) + + it('should step over an escaped ")" inside an unquoted url() argument without closing it', () => { + const state = createCssCodeScanState() + const text = 'url(a\\)b)' + + assert.strictEqual(nonCodeEnd(text, 0, state), -1) + assert.strictEqual(nonCodeEnd(text, 5, state), 7) + assert.isDefined(state.url) + assert.strictEqual(nonCodeEnd(text, 8, state), -1) + assert.isUndefined(state.url) + }) + + /** + * The CSS parser reads a url() argument as trivia (whitespace and block comments), then one + * token, then trivia again, so a block comment there is a comment only where a token can start: + * right after "(", after whitespace, or after a string or another comment. Next to url + * characters, "/" and "*" are part of the url. "//" never opens a comment inside url(). + */ + it.each([ + ['right after "("', 'url(/* ) */a.png)', '/*'], + ['after whitespace after "("', 'url( /* ) */a.png)', '/*'], + ['after whitespace after the argument', 'url(a.png /* ) */)', '/*'], + ['after a string argument', 'url("a"/* ) */)', '/*'], + ['after another comment', 'url(/* a *//* ) */b)', '/* )'], + ])('should read a block comment inside url() %s as a comment', (_description, text, marker) => { + const index = text.indexOf(marker) + const state = createCssCodeScanState() + walkCode(text, index, state) + + assert.strictEqual(nonCodeEnd(text, index, state), text.indexOf('*/', index) + 2) + assert.isDefined(state.url) + }) + + it.each([ + ['right after url characters', 'url(a/* ) */b)', '/*'], + ['right after an escape that took the space after it', 'url(a\\41 /* ) */b)', '/*'], + ['as a "//" after "("', 'url(//x)', '//'], + ])('should read "/" inside url() %s as url content', (_description, text, marker) => { + const index = text.indexOf(marker) + const state = createCssCodeScanState() + walkCode(text, index, state) + + assert.strictEqual(nonCodeEnd(text, index, state), -1) + assert.isDefined(state.url) + }) + + /** Calls nonCodeEnd at every position before `until`, stepping over each run it returns. */ + function walkCode(text: string, until: number, state: CssCodeScanState) { + for (let index = 0; index < until;) { + const end = nonCodeEnd(text, index, state) + index = end === -1 ? index + 1 : end + } + } +}) + +describe('createCodeLookback', () => { + it('should skip comments, count a string at its opening quote, and report offsets inside non-code', () => { + const text = 'a; /* b; */ "c;" // d;\n e' + const lookback = createCodeLookback(text, 0) + + assert.strictEqual(lookback.previousSignificant(0), -1) + /** Inside the block comment (3 through 10). */ + assert.strictEqual(lookback.previousSignificant(6), INSIDE_NON_CODE) + assert.strictEqual(lookback.previousSignificant(8), INSIDE_NON_CODE) + /** Past the comment, the ";" at 1 is the nearest code character. */ + assert.strictEqual(lookback.previousSignificant(12), 1) + /** Past the string (12 through 15), its opening quote. */ + assert.strictEqual(lookback.previousSignificant(17), 12) + /** Past the line comment (17 through its "\n" at 22), still the string. */ + assert.strictEqual(lookback.previousSignificant(25), 12) + assert.strictEqual(lookback.previousSignificant(26), 25) + }) + + it('should answer a query below the previous one by starting over', () => { + const text = 'a; b /* c */' + const lookback = createCodeLookback(text, 0) + + assert.strictEqual(lookback.previousSignificant(text.length), 3) + assert.strictEqual(lookback.previousSignificant(3), 1) + }) + + it('should read "//" inside an unquoted url() as code, not a comment', () => { + const text = 'url(http://x) a' + const lookback = createCodeLookback(text, 0) + + assert.strictEqual(lookback.previousSignificant(14), 12) + }) + + it('should count an escape as significant code at its backslash, never as the character it escapes', () => { + const text = 'a\\; b' + const lookback = createCodeLookback(text, 0) + + /** Inside the escape (1, 2), the backslash itself, not the ";" a statement test would match. */ + assert.strictEqual(lookback.previousSignificant(2), 1) + assert.strictEqual(lookback.previousSignificant(1), 0) + assert.strictEqual(lookback.previousSignificant(text.length - 1), 1) + }) + + it.each([ + ['a string cut off by a line break', 'a "b\nc', 4], + ['a string cut off by the end of the text', 'a "b', 4], + ['a string whose last character is an escaped quote', 'a "b\\"', 6], + ['a block comment with no closing', 'a /* b', 6], + ['a block comment ending in a lone star', 'a /*/', 5], + ['a line comment cut off by the end of the text', 'a // b', 6], + ])('should read a query at the end of %s as inside it', (_description, text, end) => { + const lookback = createCodeLookback(text, 0) + + assert.strictEqual(lookback.previousSignificant(end), INSIDE_NON_CODE) + assert.strictEqual(lookback.previousSignificant(end - 1), INSIDE_NON_CODE) + }) + + it('should keep scanning past an unterminated string after a query at its end', () => { + const text = 'a "b\nc' + const lookback = createCodeLookback(text, 0) + + assert.strictEqual(lookback.previousSignificant(4), INSIDE_NON_CODE) + assert.strictEqual(lookback.previousSignificant(text.length), 5) + }) + + it.each([ + ['before a line break', 'a "b"\nc', 5], + ['at the end of the text', 'a "b"', 5], + ['after a hex escape', 'a "\\22"', 7], + ['after an escaped backslash', 'a "\\\\"', 6], + ])( + 'should read a query at the end of a closed string %s as after it, at its opening quote', + (_description, text, end) => { + assert.strictEqual(createCodeLookback(text, 0).previousSignificant(end), 2) + }, + ) + + it.each([ + ['a block comment', 'a; /**/', 7], + ['a line comment', 'a; // b\n', 8], + ])('should read a query at the end of a closed %s as after it', (_description, text, end) => { + assert.strictEqual(createCodeLookback(text, 0).previousSignificant(end), 1) + }) + + it('should start at the given offset, ignoring characters before it', () => { + const text = ':root{\n @' + const lookback = createCodeLookback(text, ':root{\n'.length) + + assert.strictEqual(lookback.previousSignificant(text.length - 1), -1) + }) +}) diff --git a/test/unit/template-language-service.test.ts b/test/unit/template-language-service.test.ts new file mode 100644 index 0000000..9903882 --- /dev/null +++ b/test/unit/template-language-service.test.ts @@ -0,0 +1,2896 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { assert, describe, it, vi } from 'vitest' +import type { + Command, + CompletionItem, + Diagnostic, + LanguageSettings, + Range, +} from 'vscode-css-languageservice' +import { TextDocument } from 'vscode-languageserver-textdocument' +import * as vscode from 'vscode-languageserver-types' + +import { + PluginConfigurationManager, + StyledPluginConfiguration, +} from '../../src/configuration/plugin-configuration' +import { CSS_APPLY_CODE_ACTION_COMMAND } from '../../src/features/code-actions' +import { MAX_VALIDATION_CACHE_ENTRIES } from '../../src/features/diagnostics' +import { + CssLanguageService, + EmmetCompletionProvider, + StylesLanguageServiceFactory, + ScssLanguageService, +} from '../../src/features/styles-language-services' +import { StyledTemplateLanguageService } from '../../src/template-language-service' +import { getTemplateSubstitutions } from '../../src/template/template-substitutions' +import { pluginIdentity } from '../../src/tsserver/plugin-identity' +import { + StyledVirtualDocumentProvider, + VirtualDocumentProvider, +} from '../../src/virtual-document/styled-virtual-document-provider' +import { createTemplateContext as createContext } from './create-template-context' + +describe('StyledTemplateLanguageService', () => { + it('should convert CSS completion items to TypeScript completion entries', () => { + const context = createContext('color:') + const service = createService() + const completion = service.getCompletionsAtPosition(context, context.toPosition(6)) + const aliceblue = completion.entries.find((entry) => entry.name === 'aliceblue') + + assert.isDefined(aliceblue) + assert.strictEqual(aliceblue.kind, ts.ScriptElementKind.constElement) + assert.strictEqual(aliceblue.kindModifiers, 'color') + assert.deepEqual(aliceblue.replacementSpan, { start: 6, length: 0 }) + }) + + it('should return fallback details for an unknown completion entry', () => { + const context = createContext('color:') + const details = createServiceWithCompletionItems([]).getCompletionEntryDetails( + context, + context.toPosition(context.text.length), + 'missing-entry', + ) + + assert.deepEqual(details, { + name: 'missing-entry', + kind: ts.ScriptElementKind.unknown, + kindModifiers: '', + tags: [], + displayParts: [{ kind: 'text', text: 'missing-entry' }], + documentation: [], + }) + }) + + it('should omit completion edits that target the virtual document wrapper', () => { + const context = createContext('color:') + const completion = createServiceWithCompletionItems([ + createCompletionItem('wrapper', { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }), + ]).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + + assert.isUndefined(completion.entries.find((entry) => entry.name === 'wrapper')) + }) + + it('should omit completion edits that cross into the virtual document wrapper', () => { + const context = createContext('color:') + const completion = createServiceWithCompletionItems([ + createCompletionItem('crossing', { + start: { line: 0, character: 0 }, + end: { line: 1, character: 1 }, + }), + ]).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + + assert.isUndefined(completion.entries.find((entry) => entry.name === 'crossing')) + }) + + it('should map completion edits at the template end', () => { + const context = createContext('color:') + const completion = createServiceWithCompletionItems([ + createCompletionItem('end', { + start: { line: 1, character: 6 }, + end: { line: 1, character: 6 }, + }), + ]).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + const end = completion.entries.find((entry) => entry.name === 'end') + + assert.isDefined(end) + assert.deepEqual(end.replacementSpan, { start: 6, length: 0 }) + }) + + it('should derive completion boundaries from a custom virtual document provider', () => { + const context = createContext('color:') + const providerWithoutTrailer = createCustomVirtualDocumentProvider('custom{', '') + const providerWithTrailer = createCustomVirtualDocumentProvider('custom{', '') + const completionItems = (document: TextDocument) => [ + createCompletionItem('end', { + start: document.positionAt('custom{'.length + context.text.length), + end: document.positionAt('custom{'.length + context.text.length), + }), + createCompletionItem('trailer', { + start: document.positionAt('custom{'.length + context.text.length + 1), + end: document.positionAt('custom{'.length + context.text.length + 2), + }), + ] + + const completionAtEnd = createServiceWithCompletionItems( + completionItems, + providerWithoutTrailer, + ).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + const completionWithTrailer = createServiceWithCompletionItems( + completionItems, + providerWithTrailer, + ).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + + assert.deepEqual( + completionAtEnd.entries.find((entry) => entry.name === 'end')?.replacementSpan, + { start: context.text.length, length: 0 }, + ) + assert.isUndefined(completionWithTrailer.entries.find((entry) => entry.name === 'trailer')) + }) + + it('should map completion boundaries for a custom provider whose offset mapping is not a fixed shift', () => { + /** + * rawText is "aa: 1; bb: 2;" (indices: a0 a1 :2 _3 1(4) ;5 _6 b7 b8 :9 _10 2(11) ;12). The + * provider wraps it in "pre{" and inserts "???" right before raw offset 7, so the virtual text + * is "pre{" + "aa: 1; " + "???" + "bb: 2;" = "pre{aa: 1; ???bb: 2;" (indices: p0 r1 e2 {3 a4 a5 + * :6 _7 1(8) ;9 _10 ?11 ?12 ?13 b14 b15 :16 _17 2(18) ;19). A completion entry on virtual [8, 9) + * (the "1") sits before the insertion, so it maps back by the prefix alone to raw [4, 5). One on + * virtual [18, 19) (the "2") sits after it, so it maps back by the prefix plus the marker to raw + * [11, 12): a single constant shift would instead read raw [14, 15), past the marker. + */ + const rawText = 'aa: 1; bb: 2;' + const provider = createNonShiftVirtualDocumentProvider({ + insertAt: 7, + marker: '???', + prefix: 'pre{', + }) + const context = createContext(rawText) + const completionItems = (document: TextDocument) => [ + createCompletionItem('before', { + start: document.positionAt(8), + end: document.positionAt(9), + }), + createCompletionItem('after', { + start: document.positionAt(18), + end: document.positionAt(19), + }), + ] + + const completion = createServiceWithCompletionItems( + completionItems, + provider, + ).getCompletionsAtPosition(context, context.toPosition(rawText.length)) + + assert.deepEqual(completion.entries.find((entry) => entry.name === 'before')?.replacementSpan, { + start: 4, + length: 1, + }) + assert.deepEqual(completion.entries.find((entry) => entry.name === 'after')?.replacementSpan, { + start: 11, + length: 1, + }) + }) + + it('should omit the replacement span when a completion has no text edit', () => { + const context = createContext('color:') + const completion = createServiceWithCompletionItems([ + { label: 'current-position' }, + ]).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + const entry = completion.entries.find((candidate) => candidate.name === 'current-position') + + assert.isDefined(entry) + assert.isUndefined(entry.replacementSpan) + }) + + it('should map the replace range from an insert-replace completion edit', () => { + const context = createContext('color: re') + const completion = createServiceWithCompletionItems([ + { + label: 'red', + textEdit: { + newText: 'red', + insert: { + start: { line: 1, character: 8 }, + end: { line: 1, character: 9 }, + }, + replace: { + start: { line: 1, character: 7 }, + end: { line: 1, character: 9 }, + }, + }, + }, + ]).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + const entry = completion.entries.find((candidate) => candidate.name === 'red') + + assert.isDefined(entry) + assert.deepEqual(entry.replacementSpan, { start: 7, length: 2 }) + }) + + function createSnippetFormatItems(): CompletionItem[] { + return [ + { + label: 'border', + insertText: 'ignored', + insertTextFormat: vscode.InsertTextFormat.Snippet, + filterText: 'border', + textEdit: { + newText: 'border: ${1:1px} ${2:solid} ${3:black};$0', + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }, + }, + }, + { + label: 'var', + insertText: 'var($1)', + insertTextFormat: vscode.InsertTextFormat.Snippet, + }, + ] + } + + it('should never set insertText, isSnippet, or filterText on a completion, matching 1.0.1', () => { + /** + * 1.0.1's translateCompetionEntry (lib/_language-service.js) never set insertText or + * isSnippet on any entry, snippet-format or not: it built an entry from name, kind, + * kindModifiers, sortText, and replacementSpan alone and left insertion to the client's own + * handling of the entry's name. tsserver's includeCompletionsWithSnippetText preference is + * not a reliable signal for whether a client renders snippet tab stops (VS Code's TypeScript + * extension always sends it as true), so this plugin never trusts it either. + */ + const context = createContext('bor') + const completion = createServiceWithCompletionItems( + createSnippetFormatItems(), + ).getCompletionsAtPosition(context, context.toPosition(context.text.length)) + const border = completion.entries.find((entry) => entry.name === 'border') + const variable = completion.entries.find((entry) => entry.name === 'var') + + assert.isDefined(border) + assert.isUndefined(border.insertText) + assert.isUndefined(border.isSnippet) + assert.isUndefined(border.filterText) + assert.deepEqual(border.replacementSpan, { start: 0, length: 3 }) + + assert.isDefined(variable) + assert.isUndefined(variable.insertText) + assert.isUndefined(variable.isSnippet) + assert.isUndefined(variable.replacementSpan) + }) + + it('should merge CSS, filtered SCSS, and Emmet completions in order', () => { + const context = createContext('m10') + const factory = createFakeLanguageServiceFactory( + [{ label: 'css-only' }, { label: ':shared' }], + { + scssCompletions: { + isIncomplete: false, + items: [ + { label: 'filtered-property', kind: vscode.CompletionItemKind.Property }, + { label: ':scss-only', kind: vscode.CompletionItemKind.Function }, + { label: ':shared', kind: vscode.CompletionItemKind.Function }, + ], + }, + }, + ) + const emmetCompletionProvider: EmmetCompletionProvider = { + doComplete() { + return { + isIncomplete: false, + items: [{ label: ':shared' }, { label: 'emmet-only' }], + } + }, + } + const service = createServiceWithFactory(factory, { emmetCompletionProvider }) + + const completions = service.getCompletionsAtPosition( + context, + context.toPosition(context.text.length), + ) + + assert.deepEqual( + completions.entries.map((entry) => entry.name), + ['css-only', ':shared', ':scss-only', ':shared', ':shared', 'emmet-only'], + ) + assert.deepEqual(completions.metadata, { isIncomplete: true }) + }) + + describe('nested at-rule completions', () => { + const nestedAtRules = [ + '@container', + '@counter-style', + '@font-face', + '@font-palette-values', + '@keyframes', + '@layer', + '@media', + '@page', + '@property', + '@scope', + '@starting-style', + '@supports', + ] + + function atRuleEntries(completion: ts.WithMetadata) { + return completion.entries + .filter((entry) => /^@[a-z-]+$/.test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)) + } + + it.each([ + ['after a declaration', 'color: red;\n@', 'color: red;\n@'.length], + ['inside a nested rule body', '&:hover { @', '&:hover { @'.length], + ['after a closing brace', 'a { color: red; }\n@', 'a { color: red; }\n@'.length], + ['at the template start', '@', 1], + ['after a declaration and a block comment', 'color: red; /* note */ @', 24], + ['after a declaration and a line comment', 'color: red; // note\n@', 21], + /** The raw `\\` is a CSS backslash, so the quote opens no string and the ";" ends a statement. */ + ['after a declaration whose value holds a CSS-escaped quote', 'content: \\\\"a;\n@', 16], + ])( + 'should offer every nested at-rule %s, replacing the typed "@"', + (_description, text, caret) => { + const context = createContext(text, 'styled.div') + + const entries = atRuleEntries( + createService().getCompletionsAtPosition(context, context.toPosition(caret)), + ) + + assert.deepEqual( + entries, + nestedAtRules.map((name) => ({ + kind: ts.ScriptElementKind.keyword, + kindModifiers: '', + name, + replacementSpan: { length: 1, start: caret - 1 }, + sortText: name, + })), + ) + }, + ) + + it('should replace the whole at-keyword, including characters after the caret', () => { + const text = 'color: red;\n@media (min-width: 1px) {}' + const context = createContext(text, 'styled.div') + const keywordStart = text.indexOf('@') + + const entries = atRuleEntries( + createService().getCompletionsAtPosition( + context, + context.toPosition(keywordStart + '@me'.length), + ), + ) + + assert.deepEqual( + entries.map((entry) => entry.replacementSpan), + nestedAtRules.map(() => ({ length: '@media'.length, start: keywordStart })), + ) + }) + + it.each([ + ['in a property value', 'color: @'], + ['after a declaration missing its semicolon', 'color: red\n@'], + ['after a character that does not end a statement', 'a@'], + ['inside a block comment after a ";"', 'color: red; /* a; @'], + ['inside a string after a ";"', 'content: "a; @'], + ['after a CSS-escaped ";"', 'a\\\\;\n@'], + ['after a CSS-escaped "@"', 'color: red;\n\\\\@'], + ])('should not offer nested at-rules %s', (_description, text) => { + const context = createContext(text, 'styled.div') + + assert.deepEqual( + atRuleEntries( + createService().getCompletionsAtPosition(context, context.toPosition(text.length)), + ), + [], + ) + }) + + it('should read a non-ASCII name character as part of the at-keyword, as the CSS scanner does', () => { + const text = 'color: red;\n@mé' + const context = createContext(text, 'styled.div') + + const entries = atRuleEntries( + createService().getCompletionsAtPosition(context, context.toPosition(text.length)), + ) + + assert.deepEqual( + entries.map((entry) => entry.replacementSpan), + nestedAtRules.map(() => ({ length: '@mé'.length, start: text.indexOf('@') })), + ) + }) + + it('should describe a nested at-rule with the CSS language service documentation', () => { + const context = createContext('color: red;\n@', 'styled.div') + + const details = createService().getCompletionEntryDetails( + context, + context.toPosition(context.text.length), + '@media', + ) + + assert.strictEqual(details.kind, ts.ScriptElementKind.keyword) + assert.match(ts.displayPartsToString(details.documentation), /media type/i) + }) + + it('should request the at-rule catalog once per service, offer only listed names it contains, and skip labels already present', () => { + const catalogRequests: string[] = [] + const noEmmet: EmmetCompletionProvider = { doComplete: () => undefined } + const factory = createFakeLanguageServiceFactory((document) => { + if (document.getText() === '@') { + catalogRequests.push(document.languageId) + return [ + { kind: vscode.CompletionItemKind.Keyword, label: '@charset' }, + { + documentation: 'media docs', + kind: vscode.CompletionItemKind.Keyword, + label: '@media', + }, + { kind: vscode.CompletionItemKind.Keyword, label: '@supports' }, + ] + } + return [{ label: '@supports' }] + }) + const service = createServiceWithFactory(factory, { emmetCompletionProvider: noEmmet }) + const first = createContext('color: red;\n@', 'styled.div') + const second = createContext('a { @', 'styled.div') + + const firstNames = service + .getCompletionsAtPosition(first, first.toPosition(first.text.length)) + .entries.map((entry) => entry.name) + const secondNames = service + .getCompletionsAtPosition(second, second.toPosition(second.text.length)) + .entries.map((entry) => entry.name) + + assert.deepEqual(catalogRequests, ['css']) + assert.deepEqual(firstNames, ['@supports', '@media']) + assert.deepEqual(secondNames, ['@supports', '@media']) + }) + }) + + describe('Emmet placement, against the real CSS language service and Emmet', () => { + function namesAt(text: string, caret: number, tagName = 'styled.div') { + const context = createContext(text, tagName) + return createService() + .getCompletionsAtPosition(context, context.toPosition(caret)) + .entries.map((entry) => entry.name) + } + + it.each([ + ['at the template start', 'm10', 'margin: 10px;'], + ['after a declaration', 'color: red;\n p10', 'padding: 10px;'], + ['inside a nested rule body', '&:hover {\n m10', 'margin: 10px;'], + [ + 'after a declaration and a comment holding a colon', + 'color: red; /* a: b */ m10', + 'margin: 10px;', + ], + ])('should offer Emmet abbreviations %s', (_description, text, expected) => { + assert.include(namesAt(text, text.length), expected) + }) + + it.each([ + [ + 'after a property colon, before ";"', + ' display: fl;', + ' display: fl'.length, + 'flex', + 'float: left;', + ], + [ + 'after a property colon, before "}"', + '&:hover { margin: m10 }', + '&:hover { margin: m10'.length, + 'auto', + 'margin: 10px;', + ], + [ + 'after a property colon at the template end', + ' margin: m10', + ' margin: m10'.length, + 'auto', + 'margin: 10px;', + ], + [ + 'after a later value token', + ' margin: 0 m10;', + ' margin: 0 m10'.length, + 'auto', + 'margin: 10px;', + ], + [ + 'after a colon inside a string earlier in the value', + ' content: ":" m10;', + ' content: ":" m10'.length, + 'attr()', + 'margin: 10px;', + ], + [ + 'after a url() holding ";"', + ' background: url(a;b.png) m10;', + ' background: url(a;b.png) m10'.length, + 'no-repeat', + 'margin: 10px;', + ], + [ + 'after a data url() holding ":" and ";"', + ' background: url(data:image/svg+xml;utf8,) no-repeat m10;', + ' background: url(data:image/svg+xml;utf8,) no-repeat m10'.length, + 'no-repeat', + 'margin: 10px;', + ], + [ + 'after a url() holding "{" and "}"', + ' background: url(a{b}c.png) m10;', + ' background: url(a{b}c.png) m10'.length, + 'no-repeat', + 'margin: 10px;', + ], + ])( + 'should offer value completions but no Emmet abbreviation in value position %s', + (_description, text, caret, valueCompletion, emmetAbbreviation) => { + const names = namesAt(text, caret) + + assert.include(names, valueCompletion) + assert.notInclude(names, emmetAbbreviation) + }, + ) + + it.each([ + ['a hex color', ' color: #12;', ' color: #12'.length, '#121212'], + ['an !important flag', ' margin: 10px !;', ' margin: 10px !'.length, '!important'], + ])( + 'should keep an Emmet expansion that is a value, %s, in value position', + (_description, text, caret, expected) => { + assert.include(namesAt(text, caret), expected) + }, + ) + + it.each([ + [ + 'after a selector colon, with a rule body after it', + '&:hover m10 {\n color: red;\n}', + '&:hover m10'.length, + ':active', + ], + [ + 'in a media feature value', + '@media (min-width: m10) {\n color: red;\n}', + '@media (min-width: m10'.length, + 'any-hover', + ], + ])( + 'should offer completions but no Emmet declaration %s', + (_description, text, caret, expected) => { + const names = namesAt(text, caret) + + assert.include(names, expected) + assert.notInclude(names, 'margin: 10px;') + }, + ) + + it.each([ + ['in a media prelude', '@media m10 {\n color: red;\n}', '@media m10'.length], + ['in a supports prelude', '@supports m10 {\n color: red;\n}', '@supports m10'.length], + [ + 'in an at-rule prelude after a comment', + 'color: red; /* x */ @media m10 {}', + 'color: red; /* x */ @media m10'.length, + ], + ])('should offer no Emmet declaration %s', (_description, text, caret) => { + const control = 'color: red;\n m10' + + assert.notInclude(namesAt(text, caret), 'margin: 10px;') + assert.include(namesAt(control, control.length), 'margin: 10px;') + }) + + it.each([ + ['m10', 'margin: 10px;'], + ['us', 'user-select: none;'], + ])( + 'should offer no Emmet declaration for "%s" inside a url() after ";", but keep a value expansion', + (abbreviation, declaration) => { + const text = ` background: url(data:image/svg+xml;${abbreviation}) no-repeat;` + const caret = ` background: url(data:image/svg+xml;${abbreviation}`.length + const valueText = ' background: url(data:image/svg+xml;#12) no-repeat;' + const control = `color: red;\n ${abbreviation}` + + assert.notInclude(namesAt(text, caret), declaration) + assert.include(namesAt(valueText, valueText.indexOf('#12') + 3), '#121212') + assert.include(namesAt(control, control.length), declaration) + }, + ) + + it('should still offer Emmet declarations on a line before a nested rule', () => { + const text = 'color: red;\n m10\n &:hover { color: blue; }' + + assert.include(namesAt(text, 'color: red;\n m10'.length), 'margin: 10px;') + }) + + it('should offer no Emmet abbreviation inside a value-shaped css fragment', () => { + const text = '1px solid m10' + const names = namesAt(text, text.length, 'css') + + assert.include(names, 'inherit') + assert.notInclude(names, 'margin: 10px;') + }) + }) + + describe('completions inside comments and strings, against the real CSS language service', () => { + it.each([ + ['a block comment', ' /* p10 */', ' /* p10'.length], + ['a block comment holding a property prefix', ' /* dis */', ' /* dis'.length], + ['a line comment', ' // dis\n color: red;', ' // dis'.length], + ['an unterminated block comment', ' /* dis', ' /* dis'.length], + ['a string value holding an abbreviation', ' content: "w10";', ' content: "w10'.length], + ['a string value holding an at-sign', ' content: "@";', ' content: "@'.length], + ['a font name string', ' font-family: "Ar";', ' font-family: "Ar'.length], + ['an attribute selector string', ' &[data-x="a"] { color: red; }', ' &[data-x="a'.length], + [ + 'an unterminated string, at the template end', + ' content: "a; @', + ' content: "a; @'.length, + ], + [ + 'an unterminated string, at its line end', + ' content: "a; @\n color: red;', + ' content: "a; @'.length, + ], + [ + 'a string opened by a JavaScript-escaped quote, at the template end', + ' content: \\"a; @', + ' content: \\"a; @'.length, + ], + ])('should offer no completions inside %s', (_description, text, caret) => { + const context = createContext(text, 'styled.div') + const service = createService() + const control = createContext(' dis', 'styled.div') + + assert.deepEqual( + service.getCompletionsAtPosition(context, context.toPosition(caret)).entries, + [], + ) + assert.include( + service + .getCompletionsAtPosition(control, control.toPosition(control.text.length)) + .entries.map((entry) => entry.name), + 'display', + ) + }) + + it('should offer completions right after a comment closes', () => { + const text = ' /* note */ dis' + const context = createContext(text, 'styled.div') + + assert.include( + createService() + .getCompletionsAtPosition(context, context.toPosition(text.length)) + .entries.map((entry) => entry.name), + 'display', + ) + }) + }) + + it('should offer statement-position completions in an empty template', () => { + const context = createContext('', 'styled.div') + + const names = createService() + .getCompletionsAtPosition(context, { character: 0, line: 0 }) + .entries.map((entry) => entry.name) + + assert.include(names, 'display') + assert.include(names, 'color') + }) + + it('should not reuse completions between different virtual document wrappers', () => { + const service = createServiceWithCompletionItems((document) => [ + { label: document.getText().startsWith('@keyframes') ? 'keyframes' : 'root' }, + ]) + const cssContext = createContext('color:', 'css') + const keyframesContext = createContext('color:', 'keyframes') + + const cssCompletions = service.getCompletionsAtPosition( + cssContext, + cssContext.toPosition(cssContext.text.length), + ) + const keyframesCompletions = service.getCompletionsAtPosition( + keyframesContext, + keyframesContext.toPosition(keyframesContext.text.length), + ) + + assert.deepEqual( + cssCompletions.entries.map((entry) => entry.name), + ['root'], + ) + assert.deepEqual( + keyframesCompletions.entries.map((entry) => entry.name), + ['keyframes'], + ) + }) + + it('should not reuse completion or virtual-document caches across files', () => { + let cssCompletionRequests = 0 + const factory = createFakeLanguageServiceFactory(() => [ + { label: ++cssCompletionRequests === 1 ? 'first' : 'second' }, + ]) + const virtualDocumentProvider = new StyledVirtualDocumentProvider(ts) + const createVirtualDocument = vi.spyOn(virtualDocumentProvider, 'createVirtualDocument') + const service = createServiceWithFactory(factory, { virtualDocumentProvider }) + const firstContext = createContext('color:', 'css', 'first.ts') + const secondContext = createContext('color:', 'css', 'second.ts') + + const first = service.getCompletionsAtPosition( + firstContext, + firstContext.toPosition(firstContext.text.length), + ) + const second = service.getCompletionsAtPosition( + secondContext, + secondContext.toPosition(secondContext.text.length), + ) + + assert.deepEqual( + first.entries.map((entry) => entry.name), + ['first'], + ) + assert.deepEqual( + second.entries.map((entry) => entry.name), + ['second'], + ) + assert.strictEqual(createVirtualDocument.mock.calls.length, 2) + assert.strictEqual(factory.parsedDocuments.length, 2) + assert.strictEqual(factory.completionRequests, 4) + }) + + it('should convert CSS hover documentation and ranges to template offsets', () => { + const context = createContext('color: red;') + const quickInfo = createService().getQuickInfoAtPosition(context, context.toPosition(1)) + + assert.isDefined(quickInfo) + assert.deepEqual(quickInfo.textSpan, { start: 0, length: 10 }) + assert.match(ts.displayPartsToString(quickInfo.documentation), /Sets the color/i) + }) + + it('should use the request position when hover has no range', () => { + const context = createContext('color: red;') + const service = createServiceWithLanguageServiceResponses({ + hover: { contents: { kind: vscode.MarkupKind.Markdown, value: 'color docs' } }, + }) + + const quickInfo = service.getQuickInfoAtPosition(context, context.toPosition(2)) + + assert.isDefined(quickInfo) + assert.deepEqual(quickInfo?.textSpan, { start: 2, length: 1 }) + assert.deepEqual(quickInfo?.documentation, [{ kind: 'unknown', text: 'color docs' }]) + }) + + it('should keep a hover without a range inside the template at its end', () => { + const context = createContext('color: red;') + const service = createServiceWithLanguageServiceResponses({ + hover: { contents: { kind: vscode.MarkupKind.Markdown, value: 'color docs' } }, + }) + + const quickInfo = service.getQuickInfoAtPosition( + context, + context.toPosition(context.text.length), + ) + + assert.deepEqual(quickInfo?.textSpan, { length: 0, start: context.text.length }) + }) + + it('should return undefined when the language service has no hover', () => { + const context = createContext('color: red;') + + assert.isUndefined( + createServiceWithLanguageServiceResponses({}).getQuickInfoAtPosition( + context, + context.toPosition(2), + ), + ) + }) + + it('should translate diagnostic codes and severities', () => { + const context = createContext('color: red;') + const range = { + start: { line: 1, character: 0 }, + end: { line: 1, character: 1 }, + } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + range, + message: 'error', + code: 'css-error', + severity: vscode.DiagnosticSeverity.Error, + }, + { range, message: 'warning', code: 42, severity: vscode.DiagnosticSeverity.Warning }, + { range, message: 'information', severity: vscode.DiagnosticSeverity.Information }, + { range, message: 'hint', severity: vscode.DiagnosticSeverity.Hint }, + { range, message: 'default severity' }, + ], + }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ category, code, messageText, source }) => ({ + category, + code, + messageText, + source, + })), + [ + { + category: ts.DiagnosticCategory.Error, + code: 9999, + messageText: 'error', + source: pluginIdentity, + }, + { + category: ts.DiagnosticCategory.Warning, + code: 42, + messageText: 'warning', + source: pluginIdentity, + }, + { + category: ts.DiagnosticCategory.Message, + code: 9999, + messageText: 'information', + source: pluginIdentity, + }, + { + category: ts.DiagnosticCategory.Message, + code: 9999, + messageText: 'hint', + source: pluginIdentity, + }, + { + category: ts.DiagnosticCategory.Error, + code: 9999, + messageText: 'default severity', + source: pluginIdentity, + }, + ], + ) + }) + + it('should keep an end-of-template "at-rule or selector expected" diagnostic alongside the same message at a real position', () => { + /** + * vscode-css-languageservice reports ParseError.RuleOrSelectorExpected (code + * "css-ruleorselectorexpected") when the parser reaches the top level of the stylesheet, + * outside every rule, still wanting a new at-rule or selector to start. Inside this plugin's + * wrapper, that state is only reachable via the wrapper's own synthetic closing brace (a + * stray "}" or unattached ";" in the template closed it early); it is never dropped based on + * another diagnostic in the same template, even one carrying the identical message and code + * at a real position, since that would silently accept the stray "}" or ";" whenever an + * unrelated diagnostic also happened to exist. No "}" appears in this template's text, so the + * end-of-template diagnostic has nothing to re-anchor to and stays at the template end. + */ + const context = createContext('color: red;') + const templateEndPosition = { line: 1, character: context.text.length } + const realPosition = { line: 1, character: 0 } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + range: { start: templateEndPosition, end: templateEndPosition }, + message: 'at-rule or selector expected', + code: 'css-ruleorselectorexpected', + }, + { + range: { start: realPosition, end: { line: 1, character: 1 } }, + message: 'at-rule or selector expected', + code: 'css-ruleorselectorexpected', + }, + ], + }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 2) + const realDiagnostic = diagnostics.find((d) => d.start === 0) + const endOfTemplateDiagnostic = diagnostics.find((d) => d.start === context.text.length) + assert.isDefined(realDiagnostic) + assert.strictEqual(realDiagnostic?.length, 1) + assert.isDefined(endOfTemplateDiagnostic) + assert.strictEqual(endOfTemplateDiagnostic?.length, 0) + }) + + it('should keep and re-anchor a standalone end-of-template "at-rule or selector expected" diagnostic to the stray "}" that caused it', () => { + /** + * No other diagnostic reports the real problem here, so this is the template's only error: + * dropping it, as the previous test does when a real diagnostic covers the same defect, + * would silently accept the stray "}". Re-anchored to that brace's own offset (12) instead + * of the template end (13) it would otherwise clamp to. + */ + const context = createContext('color: red; }') + const templateEndPosition = { line: 1, character: context.text.length } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + range: { start: templateEndPosition, end: templateEndPosition }, + message: 'at-rule or selector expected', + code: 'css-ruleorselectorexpected', + }, + ], + }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.messageText, 'at-rule or selector expected') + assert.strictEqual(diagnostics[0]?.start, context.text.indexOf('}')) + assert.strictEqual(diagnostics[0]?.length, 1) + }) + + it('should keep a standalone end-of-template "at-rule or selector expected" diagnostic at the template end when no stray "}" is determinable', () => { + /** + * The template has no closing brace at all (the "unattached ;" or "empty template" causes + * documented on RULE_OR_SELECTOR_EXPECTED_DIAGNOSTIC_CODE), so there is nothing to re-anchor + * to: the template-end position stays, rather than being dropped. + */ + const context = createContext('color: red;') + const templateEndPosition = { line: 1, character: context.text.length } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + range: { start: templateEndPosition, end: templateEndPosition }, + message: 'at-rule or selector expected', + code: 'css-ruleorselectorexpected', + }, + ], + }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.start, context.text.length) + assert.strictEqual(diagnostics[0]?.length, 0) + }) + + it('should report a stray "}" as a real diagnostic when it is the template\'s only error, against the real CSS language service', () => { + /** + * A stray "}" that is a template's only error is reported as a real diagnostic, anchored to + * the brace itself, rather than dropped as a duplicate of the cascading "at-rule or selector + * expected" diagnostic at the template end. Uses the real vscode-css-languageservice + * (createService, not the fake diagnostics harness above) so the reported position is not + * just asserted but genuinely produced by parsing each shape. + */ + const service = createService() + + const trailingBrace = createContext('color: red; }') + const bareBrace = createContext('}') + const braceThenRule = createContext('color: red; } a { color: blue; }') + const doubleBrace = createContext('a { color: red; }}') + + const trailingBraceDiagnostics = service.getSemanticDiagnostics(trailingBrace) + const bareBraceDiagnostics = service.getSemanticDiagnostics(bareBrace) + const braceThenRuleDiagnostics = service.getSemanticDiagnostics(braceThenRule) + const doubleBraceDiagnostics = service.getSemanticDiagnostics(doubleBrace) + + assert.strictEqual(trailingBraceDiagnostics.length, 1) + assert.strictEqual(trailingBraceDiagnostics[0]?.start, trailingBrace.text.indexOf('}')) + assert.strictEqual(trailingBraceDiagnostics[0]?.length, 1) + + assert.strictEqual(bareBraceDiagnostics.length, 1) + assert.strictEqual(bareBraceDiagnostics[0]?.start, bareBrace.text.indexOf('}')) + assert.strictEqual(bareBraceDiagnostics[0]?.length, 1) + + /** The first "}" is the stray one; the rule that follows it parses cleanly on its own. */ + assert.strictEqual(braceThenRuleDiagnostics.length, 1) + assert.strictEqual(braceThenRuleDiagnostics[0]?.start, braceThenRule.text.indexOf('}')) + assert.strictEqual(braceThenRuleDiagnostics[0]?.length, 1) + + /** The first "}" closes "a { ... }"; the second, unmatched one is the stray brace. */ + assert.strictEqual(doubleBraceDiagnostics.length, 1) + assert.strictEqual(doubleBraceDiagnostics[0]?.start, doubleBrace.text.lastIndexOf('}')) + assert.strictEqual(doubleBraceDiagnostics[0]?.length, 1) + }) + + it('should keep a stray "}" alongside an unrelated diagnostic that starts before it, against the real CSS language service', () => { + /** + * "colr" is an unrelated unknown-property warning positioned before the stray "}", not the + * cascade the brace caused: dropping the brace's own diagnostic here (as a scan that treats + * any other diagnostic in the template as covering the real problem would) would silently + * accept the stray "}" whenever an unrelated error also happened to exist in the template. + */ + const service = createService() + const context = createContext('colr: red; }') + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 2) + const unknownProperty = diagnostics.find((d) => d.messageText === "Unknown property: 'colr'") + const strayBrace = diagnostics.find((d) => d.messageText === 'at-rule or selector expected') + assert.isDefined(unknownProperty) + assert.isDefined(strayBrace) + assert.strictEqual(unknownProperty?.start, context.text.indexOf('colr')) + assert.strictEqual(strayBrace?.start, context.text.indexOf('}')) + assert.strictEqual(strayBrace?.length, 1) + }) + + it('should keep a stray "}" alongside an unrelated diagnostic that starts after it, against the real CSS language service', () => { + /** + * "colr" is an unrelated unknown-property warning positioned after the stray "}": everything + * from the "}" onward still parses (".a { colr: red; }" is a rule that is valid on its own + * except for the misspelled property), so this is not the cascade the brace itself caused. + * Dropping the brace's own diagnostic whenever any other diagnostic starts anywhere in the + * template, including after it, would leave no error-level diagnostic at all here. + */ + const service = createService() + const context = createContext('color: red; } .a { colr: red; }') + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 2) + const unknownProperty = diagnostics.find((d) => d.messageText === "Unknown property: 'colr'") + const strayBrace = diagnostics.find((d) => d.messageText === 'at-rule or selector expected') + assert.isDefined(unknownProperty) + assert.isDefined(strayBrace) + assert.strictEqual(unknownProperty?.start, context.text.indexOf('colr')) + assert.strictEqual(strayBrace?.start, context.text.indexOf('}')) + assert.strictEqual(strayBrace?.length, 1) + assert.strictEqual( + strayBrace?.category, + ts.DiagnosticCategory.Error, + 'the stray brace must be an error-level diagnostic, not swallowed by the unrelated warning', + ) + }) + + it.each([ + ['line separator', String.fromCharCode(0x2028)], + ['paragraph separator', String.fromCharCode(0x2029)], + ])( + 'should find a stray "}" after a "//" comment ended by a Unicode %s, matching the virtual document normalizer', + (_description, separator) => { + /** + * normalizeVirtualText (styled-virtual-document-provider.ts) ends a "//" + * comment at a Unicode line or paragraph separator, converting it to "\n" before the CSS + * scanner ever sees it, so the "}" after it is real, structural content. findStrayClosingBraceOffset + * uses the same scanner (nonCodeEnd) so it finds the same brace instead of treating the + * comment as still open and falling back to the template-end position. + */ + const service = createService() + const context = createContext(`color: red; // note${separator} }`) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.messageText, 'at-rule or selector expected') + assert.strictEqual(diagnostics[0]?.start, context.text.indexOf('}')) + assert.strictEqual(diagnostics[0]?.length, 1) + }, + ) + + it('should not re-anchor a real "at-rule or selector expected" diagnostic that is not at the template end', () => { + /** + * isCascadeCandidate (src/features/diagnostics.ts) requires both the + * RULE_OR_SELECTOR_EXPECTED_DIAGNOSTIC_CODE and entry.start === context.rawText.length: only + * the second condition tells apart the wrapper's own cascading diagnostic (always reported at + * the template end) from a same-coded diagnostic at a real, earlier position in the template. + * Dropping the position half of that check would treat both as the cascade, re-anchoring the + * real one to the stray "}" too and losing its own position. The text contains exactly one + * stray "}" (index 2) for findStrayClosingBraceOffset to find, so a re-anchor is observable. + */ + const context = createContext('a } color: red;') + const templateEndPosition = { line: 1, character: context.text.length } + const realPosition = { line: 1, character: 4 } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + range: { start: realPosition, end: { line: 1, character: 5 } }, + message: 'at-rule or selector expected', + code: 'css-ruleorselectorexpected', + }, + { + range: { start: templateEndPosition, end: templateEndPosition }, + message: 'at-rule or selector expected', + code: 'css-ruleorselectorexpected', + }, + ], + }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 2) + const realDiagnostic = diagnostics.find((d) => d.start === 4) + const cascadeDiagnostic = diagnostics.find((d) => d.start === context.text.indexOf('}')) + assert.isDefined(realDiagnostic) + assert.strictEqual(realDiagnostic?.length, 1) + assert.isDefined(cascadeDiagnostic) + assert.strictEqual(cascadeDiagnostic?.length, 1) + }) + + it('should skip a "}" inside a block comment when scanning for the stray closing brace, against the real CSS language service', () => { + /** + * findStrayClosingBraceOffset tracks block comments so a "}" written inside one is not + * structural. Without that tracking, the scanner would anchor on the decoy "}" at index 3 + * (inside "/* } *\/") instead of the real stray "}" at the template end that actually closed + * the wrapper early. + */ + const service = createService() + const context = createContext('/* } */ color: red; }') + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.messageText, 'at-rule or selector expected') + assert.strictEqual(diagnostics[0]?.start, context.text.lastIndexOf('}')) + assert.strictEqual(diagnostics[0]?.length, 1) + }) + + it('should not mistake "//" inside an unquoted url() for a line comment when scanning for the stray closing brace, against the real CSS language service', () => { + /** + * findStrayClosingBraceOffset's scanner (nonCodeEnd) suppresses "//" line-comment detection + * while inside an unquoted url(...), because a URL such as "http://" otherwise reads as + * the start of a line comment, which would swallow the rest of the line, including the real + * stray "}" that follows, and fall back to the (wrong) template-end position instead of the + * brace itself. + */ + const service = createService() + const context = createContext('background: url(http://x.com/a.png); color: red; }') + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.messageText, 'at-rule or selector expected') + assert.strictEqual(diagnostics[0]?.start, context.text.lastIndexOf('}')) + assert.strictEqual(diagnostics[0]?.length, 1) + }) + + it.each([ + ['a CSS-escaped quote, which opens no string', 'content: \\\\"x; }'], + ['a CSS-escaped "/", which opens no comment', 'content: a\\\\//x; }'], + ['a url() whose name has a hex-escaped "u"', 'background: \\75 rl(//x.png); }'], + ['a url() whose name has an escaped "u"', 'background: \\url(//x.png); }'], + ['a url() whose name has a CSS-escaped "r"', 'background: u\\\\rl(//x.png); }'], + ['a CSS-escaped ")" inside a url()', 'background: url(a\\\\)g//x); }'], + ['a JavaScript-escaped quote, which opens a string holding the "}"', 'content: \\"}\\"; }'], + ])( + 'should anchor the stray "}" after %s, against the real CSS language service', + (_description, text) => { + const service = createService() + const context = createContext(text) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map((diagnostic) => [ + diagnostic.messageText, + diagnostic.start, + diagnostic.length, + ]), + [['at-rule or selector expected', text.lastIndexOf('}'), 1]], + ) + }, + ) + + it('should report no false error for a nested block @layer after a CSS-escaped quote, against the real CSS language service', () => { + const service = createService() + const context = createContext('content: \\\\"a; @layer u { colr: red; }', 'styled.div') + + const diagnostics = service.getSemanticDiagnostics(context) + + /** The misspelled property is the positive control: the layer body is validated as declarations. */ + assert.deepEqual( + diagnostics.map((diagnostic) => [diagnostic.messageText, diagnostic.start]), + [["Unknown property: 'colr'", context.text.indexOf('colr')]], + ) + }) + + it('should validate a css rule whose selector holds a CSS-escaped quote as a rule, not a value, against the real CSS language service', () => { + const service = createService() + const context = createContext('a\\\\"b { colr: red; }') + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map((diagnostic) => [diagnostic.messageText, diagnostic.start]), + [["Unknown property: 'colr'", context.text.indexOf('colr')]], + ) + }) + + describe('block comments inside url(), against the real CSS language service', () => { + it('should offer completions after a url() whose leading comment holds a quote', () => { + const text = 'background: url(/* " */a.png); m10' + const context = createContext(text, 'styled.div') + + const completions = createService().getCompletionsAtPosition( + context, + context.toPosition(text.length), + ) + + assert.include( + completions.entries.map((entry) => entry.name), + 'margin: 10px;', + ) + }) + + it('should validate a css value whose url() holds a leading comment with ";" as a value', () => { + const service = createService() + const context = createContext('url(/* ; */a.png) no-repeat') + /** Positive control: the same wrapper choice reports a misspelled keyword position. */ + const control = createContext('colr: red;') + + assert.deepEqual(service.getSemanticDiagnostics(context), []) + assert.deepEqual( + service.getSemanticDiagnostics(control).map((diagnostic) => diagnostic.messageText), + ["Unknown property: 'colr'"], + ) + }) + }) + + describe('spans that start or end inside a JavaScript escape, against the real CSS language service', () => { + /** `\x63` is one JavaScript escape for "c", so the name is `colr` at runtime. */ + const ESCAPED_NAME = '\\x63olr' + + it('should widen a diagnostic to cover the whole escape', () => { + const context = createContext(`${ESCAPED_NAME}: red;`) + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: ESCAPED_NAME.length, messageText: "Unknown property: 'colr'", start: 0 }], + ) + }) + + it('should report the cooked name once for an escape inside a name, widened to the whole name', () => { + /** `\x6C` is "l", so the name is `colr` at runtime: one unknown property, not a split name. */ + const name = 'co\\x6Cr' + const context = createContext(`${name}: red;`) + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: name.length, messageText: "Unknown property: 'colr'", start: 0 }], + ) + }) + + it.each([ + ['a line feed', '\n'], + ['a CRLF', '\r\n'], + ])( + 'should report only the later unknown property after a line continuation before %s inside a url(), on its own line', + (_description, lineBreak) => { + /** The stand-in holds no line break, while the line map still counts the raw one. */ + const text = `background: url(foo\\${lineBreak}bar.png);${lineBreak}colr: red;` + const context = createContext(text, 'styled.div') + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 4, messageText: "Unknown property: 'colr'", start: text.indexOf('colr') }], + ) + }, + ) + + it.each([ + ['a known property name', 'ba\\x63kground: red;'], + ['a known property name, before a hex digit', 'bor\\x64er: 0;'], + ['an unquoted url() after "/"', 'background: url(a/\\x41y.png);'], + ['an unquoted url() after ","', 'background: url(a,b\\x63.png);'], + [ + 'an unquoted url() after a cooked U+3000', + `background: url(a${String.fromCharCode(92)}u3000,\\x41);`, + ], + ['an unquoted url() after a cooked U+00A0', 'background: url(a\\xA0,\\x41);'], + [ + 'an unquoted url() after CSS-escaped quotes', + 'background: url(data:image/svg+xml,\\\\"fill\\\\"\\x41);', + ], + ['an unquoted url() after a CSS-escaped space', 'background: url(a\\\\ b\\x41);'], + ['an unquoted url() after the space a hex escape takes', 'background: url(a\\\\41 b\\x41);'], + [ + 'an unquoted url() holding a hex escape that takes a cooked line break', + 'background: url(\\x5c\\x31\\n\\\\f);', + ], + [ + 'an unquoted url() holding a hex escape a later run continues', + 'background: url(a\\\\4\\x31 b\\x41);', + ], + [ + 'an unquoted url() holding a hex escape one run writes whole', + 'background: url(a\\\\\\x34\\x31 b\\x41);', + ], + ['a value keyword', 'color: r\\x65d;'], + ['a class joined to "&"', '&.bt\\x6E { color: red; }'], + ['a pseudo-class', '&:ho\\x76er { color: red; }'], + /** + * Inside an unquoted url() argument, a run that cooks to trailing whitespace fills the padding + * before that whitespace instead of after it: after would let the CSS scanner end the url token + * at the whitespace and read the padding as a second, invalid token. + */ + ['an unquoted url() ending in a cooked line break', 'background: url(x\\n);'], + ['an unquoted url() ending in a cooked space', 'background: url(a.png\\x20);'], + /** + * A cooked quote opening the argument's first content makes the whole argument a quoted + * string, not an unquoted one: the padding is spaces, as it is outside a url(). + */ + ['an unquoted url() argument opened by cooked quotes', 'background: url(\\x22\\x22);'], + /** + * url() requires no whitespace before its own "(", unlike a general function, so a run that + * cooks to exactly "(" right after the name moves the padding in front of the name instead. + */ + ['a url() opened by a cooked "("', 'background: url\\x28x.png);'], + ])('should report nothing for an escape inside %s', (_description, text) => { + const service = createService() + + assert.deepEqual(service.getSemanticDiagnostics(createContext(text)), []) + /** Positive control: the same service reports an unknown property. */ + assert.strictEqual(service.getSemanticDiagnostics(createContext('colr: red;')).length, 1) + }) + + it('should widen a hover span to cover the whole escape', () => { + /** A known property, so hover has documentation to show; `color` at runtime. */ + const name = '\\x63olor' + const context = createContext(`${name}: red;`) + + const hover = createService().getQuickInfoAtPosition(context, context.toPosition(5)) + + /** Hover spans the whole declaration, which starts inside the escape. */ + assert.deepEqual(hover?.textSpan, { length: `${name}: red`.length, start: 0 }) + }) + + it('should widen a completion replacement span to cover the whole escape', () => { + const text = '\\x63ol' + const context = createContext(`${text}`, 'styled.div') + + const completions = createService().getCompletionsAtPosition( + context, + context.toPosition(text.length), + ) + + assert.deepEqual( + completions.entries.find((entry) => entry.name === 'color')?.replacementSpan, + { length: text.length, start: 0 }, + ) + }) + + it('should offer no code fix whose edit overlaps an escape, and keep one whose edit does not', () => { + const service = createService() + const escaped = createContext(`${ESCAPED_NAME}: red;`) + /** `\x3a` is ":"; the rename edits only `colr`, before the escape. */ + const escapeAfterName = createContext('colr\\x3a red;') + + const escapedFixes = service.getCodeFixesAtPosition(escaped, 0, ESCAPED_NAME.length) + const escapeAfterNameFixes = service.getCodeFixesAtPosition(escapeAfterName, 0, 4) + + assert.deepEqual(escapedFixes, []) + assert.deepEqual( + escapeAfterNameFixes.map((fix) => [ + fix.description, + fix.changes[0]?.textChanges[0]?.span, + fix.changes[0]?.textChanges[0]?.newText, + ]), + [ + ["Rename to 'color'", { length: 4, start: 0 }, 'color'], + ["Rename to 'clear'", { length: 4, start: 0 }, 'clear'], + ["Rename to 'clip'", { length: 4, start: 0 }, 'clip'], + ], + ) + }) + }) + + it('should skip a "}" inside an unquoted url() when scanning for the stray closing brace, against the real CSS language service', () => { + const context = createContext('background: url(a}b.png); color: red; }', 'styled.div') + + assert.deepEqual( + createService() + .getSemanticDiagnostics(context) + .map(({ length, messageText, start }) => ({ length, messageText, start })), + [ + { + length: 1, + messageText: 'at-rule or selector expected', + start: context.text.lastIndexOf('}'), + }, + ], + ) + }) + + it('should skip a "}" inside a string after a CSS-escaped quote when scanning for the stray closing brace, against the real CSS language service', () => { + /** + * The raw `\\` is a CSS backslash that escapes the quote after it, so the string still holds + * the "}" at index 14. Without escape handling, the string would close at the escaped quote and + * the scanner would anchor on that decoy "}" instead of the real stray "}" at the template end. + */ + const service = createService() + const context = createContext('content: "a\\\\"}"; }') + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.messageText, 'at-rule or selector expected') + assert.strictEqual(diagnostics[0]?.start, context.text.lastIndexOf('}')) + assert.strictEqual(diagnostics[0]?.length, 1) + }) + + /** + * The stray-brace scan reads strings, escapes, and "//" comments the way the CSS scanner does: + * an escaped line break (a "\r\n" pair counts as one) and a hex escape's trailing line break stay + * inside the string, while an unescaped "\f" ends a string or a "//" comment just as "\n" does. + * Each expected offset is the "}" the CSS scanner reads as code, counted by hand. + */ + it.each([ + /** `"a\` + LF: the string runs through `}"` (13, 14), so the stray "}" is the last one, at 17. */ + ['an escaped LF inside a string', 'content: "a\\\n}"; }', 17], + /** `"a\` + CRLF: the escape covers both "\r" (12) and "\n" (13); the stray "}" is at 18. */ + ['an escaped CRLF inside a string', 'content: "a\\\r\n}"; }', 18], + /** `"\41` + LF: the hex escape absorbs the LF (13), so the string runs through `}"`; stray "}" at 18. */ + ['a hex escape followed by LF inside a string', 'content: "\\41\n}"; }', 18], + /** `"a` + FF: the string ends before the "\f" (11), so the "}" at 12 closes the wrapper. */ + ['a form feed ending a string', 'content: "a\f}', 12], + /** `// x` + FF: the comment ends at the "\f" (16), so the "}" at 17 is code. */ + ['a form feed ending a "//" comment', 'color: red; // x\f}', 17], + ])( + 'should find the stray "}" after %s, matching the CSS scanner, against the real CSS language service', + (_description, text, strayBraceOffset) => { + const context = createContext(text) + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 1, messageText: 'at-rule or selector expected', start: strayBraceOffset }], + ) + }, + ) + + it("should report the CSS language service's own diagnostic on the bad string after a form-feed-ended string closes the wrapper early", () => { + /** + * `"a` ends before the "\f" (11), the "}" at 12 closes the wrapper, and `"; }` (13 through the + * template end, 4 characters) is a second, unterminated string at the top level, which the CSS + * language service itself flags at its own position; no end-of-template diagnostic remains to + * re-anchor. + */ + const context = createContext('content: "a\f}"; }') + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 4, messageText: 'at-rule or selector expected', start: 13 }], + ) + }) + + describe('nested block @layer, against the real CSS language service', () => { + it.each([ + ['a named layer', '@layer utilities {\n color: red;\n}'], + [ + 'a dotted layer name inside a rule', + '&:hover {\n @layer framework.base { color: red; }\n}', + ], + ['an anonymous layer', '@layer {\n color: red;\n}'], + ])('should report no diagnostics for declarations in %s', (_description, text) => { + assert.deepEqual( + createService().getSemanticDiagnostics(createContext(text, 'styled.div')), + [], + ) + }) + + it('should map a real diagnostic inside a nested layer to its own position', () => { + const context = createContext('@layer utilities {\n colr: red;\n}', 'styled.div') + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [ + { + length: 'colr'.length, + messageText: "Unknown property: 'colr'", + start: context.text.indexOf('colr'), + }, + ], + ) + }) + + it('should offer property completions and hover inside a nested layer', () => { + const completionContext = createContext('@layer utilities {\n opa', 'styled.div') + const hoverContext = createContext('@layer utilities {\n color: red;\n}', 'styled.div') + const service = createService() + + const opacity = service + .getCompletionsAtPosition( + completionContext, + completionContext.toPosition(completionContext.text.length), + ) + .entries.find((entry) => entry.name === 'opacity') + const hover = service.getQuickInfoAtPosition( + hoverContext, + hoverContext.toPosition(hoverContext.text.indexOf('color') + 1), + ) + + assert.deepEqual(opacity?.replacementSpan, { + length: 'opa'.length, + start: completionContext.text.indexOf('opa'), + }) + assert.deepEqual(hover?.textSpan, { + length: 'color: red'.length, + start: hoverContext.text.indexOf('color'), + }) + assert.match(ts.displayPartsToString(hover?.documentation), /Sets the color/i) + }) + + it('should fold a nested layer from its opening line to its last body line', () => { + const text = '@layer utilities {\n color: red;\n}' + + assert.deepEqual( + createService() + .getOutliningSpans(createContext(text, 'styled.div')) + .map((span) => span.textSpan), + [{ length: text.indexOf('\n') + 1, start: 0 }], + ) + }) + + it('should still report a declaration directly in a global-style template top-level layer', () => { + /** + * A global-style template's top level is the stylesheet's top level, where a layer body holds + * rules, not declarations: the prelude stays untouched there, so the parser's own error + * stands. + */ + const context = createContext('@layer base { color: red; }', 'createGlobalStyle') + + assert.deepEqual( + createService() + .getSemanticDiagnostics(context) + .map(({ messageText, start }) => ({ messageText, start })), + [{ messageText: '{ expected', start: context.text.indexOf(':') }], + ) + }) + }) + + describe('value-shaped css fragments, against the real CSS language service', () => { + it.each([ + ['a border value', '1px solid red'], + ['an animation value after a whitespace-filled placeholder', ' 1s linear'], + ['a multi-line animation value', '\n spin 2s linear infinite\n'], + ['a background value with a url', 'url(http://x/a.png) no-repeat'], + ])('should report no diagnostics for %s', (_description, text) => { + assert.deepEqual(createService().getSemanticDiagnostics(createContext(text)), []) + }) + + it.each([ + ['a keyword', 'red'], + ['"none"', 'none'], + ['a unit after a whitespace-filled placeholder', ' px'], + ['an unknown name', 'colr'], + ['a name on its own line', '\n dis\n'], + ['a name before an unclosed comment', 'red /* note'], + ])( + 'should report no diagnostics for a single identifier, %s, which reads as a value as much as a property name', + (_description, text) => { + const service = createService() + + assert.deepEqual(service.getSemanticDiagnostics(createContext(text)), []) + /** Positive control: a declaration in the same kind of fragment still reports. */ + assert.deepEqual( + service + .getSemanticDiagnostics(createContext('colr: red;')) + .map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 'colr'.length, messageText: "Unknown property: 'colr'", start: 0 }], + ) + }, + ) + + it('should keep a diagnostic of a single-identifier fragment that both readings report', () => { + const context = createContext('red') + const position = { line: 1, character: 0 } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + code: 'css-lint-example', + message: 'reported under either wrapper', + range: { end: position, start: position }, + }, + ], + }) + + assert.deepEqual( + service + .getSemanticDiagnostics(context) + .map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 0, messageText: 'reported under either wrapper', start: 0 }], + ) + }) + + it('should clamp a value diagnostic on the closing wrapper to the template end', () => { + const context = createContext('rgba(0, 0, 0') + + assert.deepEqual( + createService() + .getSemanticDiagnostics(context) + .map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 0, messageText: ') expected', start: context.text.length }], + ) + }) + + it('should offer value completions, not property names, inside a value fragment', () => { + const context = createContext('1px solid ') + const names = createService() + .getCompletionsAtPosition(context, context.toPosition(context.text.length)) + .entries.map((entry) => entry.name) + + assert.include(names, 'inherit') + assert.notInclude(names, 'display') + }) + + it('should keep property completions while the first declaration of a css fragment is typed', () => { + const context = createContext('\n dis') + const display = createService() + .getCompletionsAtPosition(context, context.toPosition(context.text.length)) + .entries.find((entry) => entry.name === 'display') + + assert.deepEqual(display?.replacementSpan, { + length: 'dis'.length, + start: context.text.indexOf('dis'), + }) + }) + }) + + it('should keep a real end-of-template diagnostic whose code is not "at-rule or selector expected"', () => { + const context = createContext('color: red;') + const templateEndPosition = { line: 1, character: context.text.length } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [ + { + range: { start: templateEndPosition, end: templateEndPosition }, + message: '} expected', + code: 'css-rcurlyexpected', + }, + ], + }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.strictEqual(diagnostics.length, 1) + assert.strictEqual(diagnostics[0]?.messageText, '} expected') + assert.strictEqual(diagnostics[0]?.start, context.text.length) + }) + + it('should omit diagnostics and hover ranges that cross the virtual document wrapper', () => { + const context = createContext('color: red;') + const range = { + start: { line: 0, character: 0 }, + end: { line: 1, character: 1 }, + } + const service = createServiceWithLanguageServiceResponses({ + diagnostics: [{ range, message: 'wrapper diagnostic' }], + hover: { range, contents: 'wrapper hover' }, + }) + + assert.deepEqual(service.getSemanticDiagnostics(context), []) + assert.isUndefined(service.getQuickInfoAtPosition(context, context.toPosition(1))) + }) + + it('should omit code actions when any edit targets the virtual document wrapper', () => { + const context = createContext('color: reed;') + const inside = createRenameCodeAction({ + end: 11, + newText: 'red', + start: 7, + title: 'Fix inside', + }) + const service = createServiceWithLanguageServiceResponses({ + codeActions: [ + { + title: 'Fix color', + command: CSS_APPLY_CODE_ACTION_COMMAND, + arguments: [ + undefined, + undefined, + [ + { + range: { + start: { line: 1, character: 7 }, + end: { line: 1, character: 11 }, + }, + newText: 'red', + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + newText: '', + }, + ], + ], + }, + inside, + ], + diagnostics: [createLineOneDiagnostic(7, 11)], + }) + + assert.deepEqual( + service + .getCodeFixesAtPosition(context, 0, context.text.length, [9999]) + .map((fix) => fix.description), + ['Fix inside'], + ) + }) + + it('should ignore unsupported code action commands and commands without edits', () => { + const context = createContext('boarder: 1px solid black;') + const service = createServiceWithLanguageServiceResponses({ + codeActions: [ + { title: 'External action', command: 'external.action' }, + { title: 'Missing edits', command: CSS_APPLY_CODE_ACTION_COMMAND }, + RENAME_TO_BORDER, + ], + diagnostics: [createLineOneDiagnostic(0, 7)], + }) + + assert.deepEqual( + service.getCodeFixesAtPosition(context, 0, 7, [9999]).map((fix) => fix.description), + ["Rename to 'border'"], + ) + }) + + it('should support the legacy three-argument code fix API', () => { + const context = createContext('boarder: 1px solid black;') + const service = createServiceWithLanguageServiceResponses({ + codeActions: [RENAME_TO_BORDER], + diagnostics: [createLineOneDiagnostic(0, 7)], + }) + + const fixes = service.getCodeFixesAtPosition(context, 0, 7) + + assert.deepEqual(fixes, [ + { + description: "Rename to 'border'", + changes: [ + { + fileName: context.fileName, + textChanges: [{ newText: 'border', span: { start: 0, length: 7 } }], + }, + ], + }, + ]) + }) + + it('should offer no code fix for a diagnostic the single-identifier intersection suppresses', () => { + /** Reported under the declaration wrapper only, so the value reading drops it. */ + const rootReadingOnly = (document: TextDocument) => + document.getText().startsWith(':root{\n') + ? [ + { + code: 'unknownProperties', + message: "Unknown property: 'colr'", + range: { end: { line: 1, character: 4 }, start: { line: 1, character: 0 } }, + }, + ] + : [] + const service = createServiceWithLanguageServiceResponses({ + codeActions: [ + createRenameCodeAction({ end: 4, newText: 'color', start: 0, title: "Rename to 'color'" }), + ], + diagnostics: rootReadingOnly, + }) + const suppressed = createContext('colr') + const control = createContext('colr: red;') + + assert.deepEqual(service.getSemanticDiagnostics(suppressed), []) + assert.deepEqual(service.getCodeFixesAtPosition(suppressed, 0, 4), []) + assert.deepEqual( + service.getCodeFixesAtPosition(control, 0, 4).map((fix) => fix.description), + ["Rename to 'color'"], + ) + }) + + it('should answer a code fix request from the validation cache the diagnostics request filled', () => { + const position = { line: 1, character: 0 } + const factory = createFakeLanguageServiceFactory([], { + diagnostics: [ + { + message: 'unknown property', + range: { end: { line: 1, character: 4 }, start: position }, + }, + ], + }) + const service = createServiceWithFactory(factory) + const context = createContext('colr: red;') + + service.getSemanticDiagnostics(context) + service.getCodeFixesAtPosition({ ...context }, 0, 4) + + assert.strictEqual(factory.validationRequests, 1) + assert.strictEqual(factory.codeActionRequests, 1) + }) + + it('should return the supported code fix code without building a language service', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + + assert.deepEqual(service.getSupportedCodeFixes(), [9999]) + assert.deepEqual(factory.scssConfigurations, []) + }) + + it('should convert nested CSS folding ranges to template offsets', () => { + const context = createContext(['a {', ' color: red;', '}', 'div {', '', '}'].join('\n')) + const spans = createService().getOutliningSpans(context) + + assert.strictEqual(spans.length, 2) + assert.deepEqual(spans[0]?.textSpan, { start: 0, length: 4 }) + assert.deepEqual(spans[1]?.textSpan, { start: 20, length: 6 }) + }) + + it('should default missing folding range characters to the start of each line', () => { + const context = createContext(['a {', ' color: red;', '}'].join('\n')) + const service = createServiceWithLanguageServiceResponses({ + foldingRanges: [{ startLine: 1, endLine: 2 }], + }) + + assert.deepEqual(service.getOutliningSpans(context), [ + { + autoCollapse: false, + kind: ts.OutliningSpanKind.Code, + bannerText: '', + textSpan: { start: 0, length: 4 }, + hintSpan: { start: 0, length: 4 }, + }, + ]) + }) + + it('should omit folding ranges that target the virtual document wrapper', () => { + const context = createContext('color: red;') + const service = createServiceWithLanguageServiceResponses({ + foldingRanges: [{ startLine: 0, endLine: 1, endCharacter: 1 }], + }) + + assert.deepEqual(service.getOutliningSpans(context), []) + }) + + it('should use injected language services and reconfigure them on updates', () => { + const manager = new PluginConfigurationManager() + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory, { configurationManager: manager }) + + service.getCompletionsAtPosition(createContext('color:'), { line: 0, character: 6 }) + service.getCompletionsAtPosition(createContext('display:'), { line: 0, character: 8 }) + const initialConfiguration = manager.config + manager.updateFromPluginConfig({ tags: ['sty'] }) + + assert.strictEqual(factory.completionRequests, 4) + assert.deepEqual(manager.config.tags, ['sty']) + for (const configurations of [factory.cssConfigurations, factory.scssConfigurations]) { + assert.strictEqual(configurations.length, 2) + assert.strictEqual(configurations[0], initialConfiguration) + assert.strictEqual(configurations[1], manager.config) + } + }) + + it('should invalidate completion results when configuration changes', () => { + const manager = new PluginConfigurationManager() + const emmetConfigurations: StyledPluginConfiguration['emmet'][] = [] + const emmetCompletionProvider: EmmetCompletionProvider = { + doComplete(_document, _position, configuration) { + emmetConfigurations.push(configuration) + return { + isIncomplete: false, + items: [{ label: configuration.showSuggestionsAsSnippets ? 'updated' : 'initial' }], + } + }, + } + const service = createServiceWithFactory(createFakeLanguageServiceFactory(), { + configurationManager: manager, + emmetCompletionProvider, + }) + const context = createContext('m10') + const position = context.toPosition(context.text.length) + + const initial = service.getCompletionsAtPosition(context, position) + service.getCompletionsAtPosition(context, position) + manager.updateFromPluginConfig({ emmet: { showSuggestionsAsSnippets: true } }) + const updated = service.getCompletionsAtPosition(context, position) + + assert.strictEqual(emmetConfigurations.length, 2) + assert.isUndefined(emmetConfigurations[0]?.showSuggestionsAsSnippets) + assert.isTrue(emmetConfigurations[1]?.showSuggestionsAsSnippets) + assert.isDefined(initial.entries.find((entry) => entry.name === 'initial')) + assert.isDefined(updated.entries.find((entry) => entry.name === 'updated')) + assert.isUndefined(updated.entries.find((entry) => entry.name === 'initial')) + }) + + it('should reuse parsed virtual documents across features and invalidate changed contexts', () => { + const factory = createFakeLanguageServiceFactory() + const virtualDocumentProvider = new StyledVirtualDocumentProvider(ts) + const createVirtualDocument = vi.spyOn(virtualDocumentProvider, 'createVirtualDocument') + const service = createServiceWithFactory(factory, { virtualDocumentProvider }) + const context = createContext('color:') + + service.getSemanticDiagnostics(context) + service.getQuickInfoAtPosition(context, context.toPosition(1)) + service.getCompletionsAtPosition(context, context.toPosition(context.text.length)) + service.getCodeFixesAtPosition(context, 0, context.text.length, [9999]) + service.getOutliningSpans(context) + + assert.strictEqual(createVirtualDocument.mock.calls.length, 1) + assert.strictEqual(factory.parsedDocuments.length, 1) + + service.getSemanticDiagnostics(createContext('display:')) + assert.strictEqual(createVirtualDocument.mock.calls.length, 2) + assert.strictEqual(factory.parsedDocuments.length, 2) + + service.getSemanticDiagnostics(createContext('display:', 'keyframes')) + assert.strictEqual(createVirtualDocument.mock.calls.length, 3) + assert.strictEqual(factory.parsedDocuments.length, 3) + assert.match(factory.parsedDocuments[2]?.getText() ?? '', /^@keyframes/) + }) + + it('should reuse a raw validation result for an unchanged template (hit)', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + + service.getSemanticDiagnostics(createContext('color: red;')) + service.getSemanticDiagnostics(createContext('color: red;')) + + assert.strictEqual(factory.validationRequests, 1) + }) + + it('should re-validate after the template text changes (miss)', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + + service.getSemanticDiagnostics(createContext('color: red;')) + service.getSemanticDiagnostics(createContext('color: blue;')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should not collapse two templates whose raw text differs only in an unpaired surrogate into the same cache entry', () => { + /** + * The validation cache key is a flattened copy of wrapper plus raw text (flattenCacheKey, + * src/features/diagnostics.ts): flattening through a UTF-8 round trip would rewrite every + * unpaired surrogate to U+FFFD, making two templates that differ only in which unpaired + * surrogate they contain collide on the same cache entry and serve one template's stale + * diagnostics for the other. + */ + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + const highSurrogate = String.fromCharCode(0xd800) + const lowSurrogate = String.fromCharCode(0xdc00) + + service.getSemanticDiagnostics(createContext(`color: a${highSurrogate}b;`)) + service.getSemanticDiagnostics(createContext(`color: a${lowSurrogate}b;`)) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should validate a single-identifier css fragment under both readings once, then answer from the cache', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + + service.getSemanticDiagnostics(createContext('red')) + service.getSemanticDiagnostics(createContext('red')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should re-validate every cached template after configuration changes (miss)', () => { + const manager = new PluginConfigurationManager() + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory, { configurationManager: manager }) + + service.getSemanticDiagnostics(createContext('color: red;')) + manager.updateFromPluginConfig({ lint: { unknownProperties: 'error' } }) + service.getSemanticDiagnostics(createContext('color: red;')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should evict the least recently used raw validation result once the cache is full', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + /** One more template than MAX_VALIDATION_CACHE_ENTRIES (src/features/diagnostics.ts) so the first entry is evicted before it is asked for again. */ + const templateCount = MAX_VALIDATION_CACHE_ENTRIES + 1 + + for (let index = 0; index < templateCount; index++) { + service.getSemanticDiagnostics(createContext(`.rule-${index} { color: red; }`)) + } + assert.strictEqual(factory.validationRequests, templateCount) + + service.getSemanticDiagnostics(createContext('.rule-0 { color: red; }')) + assert.strictEqual(factory.validationRequests, templateCount + 1) + + service.getSemanticDiagnostics(createContext(`.rule-${templateCount - 1} { color: red; }`)) + assert.strictEqual(factory.validationRequests, templateCount + 1) + }) + + it("should refresh an entry's recency on a hit, so it survives an eviction an untouched entry would not", () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + + for (let index = 0; index < MAX_VALIDATION_CACHE_ENTRIES; index++) { + service.getSemanticDiagnostics(createContext(`.rule-${index} { color: red; }`)) + } + assert.strictEqual(factory.validationRequests, MAX_VALIDATION_CACHE_ENTRIES) + + /** A hit on the oldest entry (rule-0) moves it to the newest end of the LRU order. */ + service.getSemanticDiagnostics(createContext('.rule-0 { color: red; }')) + assert.strictEqual(factory.validationRequests, MAX_VALIDATION_CACHE_ENTRIES) + + /** One new template forces exactly one eviction: the now-oldest entry (rule-1), not the just-refreshed rule-0. */ + service.getSemanticDiagnostics( + createContext(`.rule-${MAX_VALIDATION_CACHE_ENTRIES} { color: red; }`), + ) + const requestsAfterInsert = factory.validationRequests + + service.getSemanticDiagnostics(createContext('.rule-0 { color: red; }')) + assert.strictEqual(factory.validationRequests, requestsAfterInsert) + + service.getSemanticDiagnostics(createContext('.rule-1 { color: red; }')) + assert.strictEqual(factory.validationRequests, requestsAfterInsert + 1) + }) + + it('should not reuse documents across contexts for a custom provider without a cache key', () => { + const factory = createFakeLanguageServiceFactory() + const provider: VirtualDocumentProvider = { + ...createCustomVirtualDocumentProvider('', ''), + createVirtualDocument(context) { + return TextDocument.create('untitled://custom.scss', 'scss', 1, context.rawText) + }, + } + const service = createServiceWithFactory(factory, { virtualDocumentProvider: provider }) + const context = createContext('same') + + service.getSemanticDiagnostics({ ...context, rawText: 'first' }) + service.getSemanticDiagnostics({ ...context, rawText: 'second' }) + + assert.deepEqual( + factory.parsedDocuments.map((document) => document.getText()), + ['first', 'second'], + ) + }) + + it('should not reuse a raw validation result for a custom provider without canReuseVirtualDocument', () => { + /** + * The raw validation cache assumes a virtual document (and therefore doValidation's result) + * is a pure function of wrapper plus raw text, an assumption canReuseVirtualDocument is what + * proves for the built-in provider (docs/architecture.md, Caching): a provider without it + * gets no reuse here either, matching the single-entry virtual-document cache. + */ + const factory = createFakeLanguageServiceFactory() + const provider: VirtualDocumentProvider = { + ...createCustomVirtualDocumentProvider('', ''), + createVirtualDocument(context) { + return TextDocument.create('untitled://custom.scss', 'scss', 1, context.rawText) + }, + } + const service = createServiceWithFactory(factory, { virtualDocumentProvider: provider }) + + service.getSemanticDiagnostics(createContext('color: red;')) + service.getSemanticDiagnostics(createContext('color: red;')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should key the raw validation cache by wrapper as well as raw text, so "css" and "keyframes" templates with the same text do not share an entry', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + + service.getSemanticDiagnostics(createContext('color: red;', 'css')) + service.getSemanticDiagnostics(createContext('color: red;', 'keyframes')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should not reuse a raw validation result for a third-party provider whose canReuseVirtualDocument returns false', () => { + /** + * The memo trusts only the built-in StyledVirtualDocumentProvider (checked by identity, not + * by merely finding a same-named method): a third-party provider that defines + * canReuseVirtualDocument, even one that itself always answers false, must still get no reuse + * here, since this cache never calls the method at all and a provider merely shaped like the + * interface is not proof its virtual document is a pure function of wrapper plus raw text. + */ + const factory = createFakeLanguageServiceFactory() + const provider: VirtualDocumentProvider = { + ...createCustomVirtualDocumentProvider('', ''), + createVirtualDocument(context) { + return TextDocument.create('untitled://custom.scss', 'scss', 1, context.rawText) + }, + canReuseVirtualDocument() { + return false + }, + } + const service = createServiceWithFactory(factory, { virtualDocumentProvider: provider }) + + service.getSemanticDiagnostics(createContext('color: red;')) + service.getSemanticDiagnostics(createContext('color: red;')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should not reuse a raw validation result for a third-party provider whose canReuseVirtualDocument returns true', () => { + const factory = createFakeLanguageServiceFactory() + const provider: VirtualDocumentProvider = { + ...createCustomVirtualDocumentProvider('', ''), + createVirtualDocument(context) { + return TextDocument.create('untitled://custom.scss', 'scss', 1, context.rawText) + }, + canReuseVirtualDocument() { + return true + }, + } + const service = createServiceWithFactory(factory, { virtualDocumentProvider: provider }) + + service.getSemanticDiagnostics(createContext('color: red;')) + service.getSemanticDiagnostics(createContext('color: red;')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should not collide two distinct (reading key, rawText) pairs whose plain concatenation would be identical', () => { + /** + * A subclass of the built-in provider still passes the identity check the memo trusts, so it + * can otherwise reuse a stale result across two calls whose reading key and rawText + * concatenate to the same characters ("a" + "b;c" and "ab" + ";c" both concatenate to + * "ab;c"): the key must keep them apart by length-prefixing the reading key + * (buildValidationCacheKey) rather than by plain string concatenation. + */ + class OverriddenReadingProvider extends StyledVirtualDocumentProvider { + public readingKeyOverride = '' + public override getReadingKey(): string { + return this.readingKeyOverride + } + } + const factory = createFakeLanguageServiceFactory() + const provider = new OverriddenReadingProvider(ts) + const service = createServiceWithFactory(factory, { virtualDocumentProvider: provider }) + + provider.readingKeyOverride = 'a' + service.getSemanticDiagnostics(createContext('b;c')) + provider.readingKeyOverride = 'ab' + service.getSemanticDiagnostics(createContext(';c')) + + assert.strictEqual(factory.validationRequests, 2) + }) + + it('should not create a virtual document on a raw validation cache hit', () => { + /** + * A cache hit must not build (and evict the session's single-entry cache with) a virtual + * document purely to resolve positions for diagnostics the raw validation cache already + * answers: the session's document cache holds "color: blue;" (the most recent request) when + * "color: red;" comes around again, so a hit for "color: red;" must derive its TemplateLineMap + * directly from context.rawText instead of rebuilding a document for it. + */ + const factory = createFakeLanguageServiceFactory() + const virtualDocumentProvider = new StyledVirtualDocumentProvider(ts) + const createVirtualDocument = vi.spyOn(virtualDocumentProvider, 'createVirtualDocument') + const service = createServiceWithFactory(factory, { virtualDocumentProvider }) + + service.getSemanticDiagnostics(createContext('color: red;')) + service.getSemanticDiagnostics(createContext('color: blue;')) + assert.strictEqual(createVirtualDocument.mock.calls.length, 2) + + service.getSemanticDiagnostics(createContext('color: red;')) + + assert.strictEqual(createVirtualDocument.mock.calls.length, 2) + assert.strictEqual(factory.validationRequests, 2) + }) + + describe('substitution on unchanged templates', () => { + /** + * The decorator substitutes a template's placeholders lazily, on the first read of + * `TemplateContext.text`, and builds a fresh context per request. A request whose answer is + * already cached (a validation hit, a reusable document) has no use for the substituted text, + * so it must not force it. + */ + it('should substitute placeholders in the counting context, once per context', () => { + const counter = { count: 0 } + const context = createSubstitutingContext('color: ${c};', 'styled.div', counter) + + assert.strictEqual(context.text, 'color: xxxx;') + assert.strictEqual(context.text, 'color: xxxx;') + assert.strictEqual(counter.count, 1) + }) + + it('should substitute nothing for a diagnostics request whose templates are all validation cache hits', () => { + const counter = { count: 0 } + const service = createService() + const templates: Array<[string, string]> = [ + ['color: ${color};\npadding: ${pad}px;', 'styled.div'], + ['colr: ${value};', 'styled.div'], + ['colr', 'css'], + ['${fadeIn} 1s linear', 'css'], + ] + const request = () => + templates.map(([text, tagName]) => + service + .getSemanticDiagnostics(createSubstitutingContext(text, tagName, counter)) + .map(({ length, messageText, start }) => ({ length, messageText, start })), + ) + + const first = request() + assert.strictEqual(counter.count, templates.length, 'each first request substitutes once') + assert.deepEqual(first, [ + [], + [{ length: 4, messageText: "Unknown property: 'colr'", start: 0 }], + [], + [], + ]) + + counter.count = 0 + const second = request() + assert.strictEqual(counter.count, 0) + assert.deepEqual(second, first) + }) + + it.each([ + ['styled.div', 'color: ${color};\nmargin: 0;'], + ['css', 'color: ${color};\nmargin: 0;'], + ])('should substitute nothing for a hover on a reusable document: %s', (tagName, text) => { + const counter = { count: 0 } + const service = createService() + const hover = () => { + const context = createSubstitutingContext(text, tagName, counter) + return service.getQuickInfoAtPosition(context, context.toPosition(1)) + } + + const first = hover() + assert.strictEqual(counter.count, 1, 'the first request substitutes once') + /** A property hover spans its whole declaration, the ";" excluded. */ + assert.deepEqual(first?.textSpan, { length: 'color: ${color}'.length, start: 0 }) + + counter.count = 0 + const second = hover() + assert.strictEqual(counter.count, 0) + assert.deepEqual(second, first) + }) + + it.each([ + ['styled.div', 'color: ${color};\nmargin: 0;'], + ['css', 'color: ${color};\nmargin: 0;'], + ])( + 'should substitute nothing for a completion at a new position on unchanged text: %s', + (tagName, text) => { + const counter = { count: 0 } + const service = createService() + const complete = (offset: number) => { + const context = createSubstitutingContext(text, tagName, counter) + return service.getCompletionsAtPosition(context, context.toPosition(offset)) + } + const secondOffset = text.indexOf('margin') + 3 + + complete(3) + assert.strictEqual(counter.count, 1, 'the first request substitutes once') + + counter.count = 0 + const second = complete(secondOffset) + assert.strictEqual(counter.count, 0) + + const freshContext = createSubstitutingContext(text, tagName, { count: 0 }) + const fresh = createService().getCompletionsAtPosition( + freshContext, + freshContext.toPosition(secondOffset), + ) + assert.isTrue(second.entries.some((entry) => entry.name === 'margin')) + assert.deepEqual(second, fresh) + }, + ) + }) + + it('should not reuse completions across contexts for a custom provider without a cache key', () => { + const provider: VirtualDocumentProvider = { + ...createCustomVirtualDocumentProvider('', ''), + createVirtualDocument(context) { + return TextDocument.create('untitled://custom.scss', 'scss', 1, context.rawText) + }, + } + const service = createServiceWithCompletionItems( + (document) => [{ label: document.getText() }], + provider, + ) + const context = createContext('same') + const position = context.toPosition(context.text.length) + + const first = service.getCompletionsAtPosition({ ...context, rawText: 'first' }, position) + const second = service.getCompletionsAtPosition({ ...context, rawText: 'second' }, position) + + assert.deepEqual( + first.entries.map((entry) => entry.name), + ['first'], + ) + assert.deepEqual( + second.entries.map((entry) => entry.name), + ['second'], + ) + }) + + it('should reuse the translated completion result across contexts with the same raw text and position', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + const context = createContext('color:') + const position = context.toPosition(context.text.length) + + const first = service.getCompletionsAtPosition(context, position) + const requestsAfterFirst = factory.completionRequests + const second = service.getCompletionsAtPosition({ ...context }, position) + + /** + * The second call reuses a different (but raw-text-identical) context object: proves the + * cached, already-translated result is returned rather than re-running the underlying CSS + * and SCSS completion requests (position translation itself is a pure function of raw text + * and position, so it is not a useful signal here, see template-line-map.ts). The first call + * makes one CSS and one SCSS request; "color:" holds no at-keyword, so no catalog request. + */ + assert.strictEqual(requestsAfterFirst, 2) + assert.strictEqual(factory.completionRequests, requestsAfterFirst) + assert.deepEqual(second, first) + }) + + it('should return a completion result a caller can freely mutate without corrupting a later cache hit', () => { + /** + * ./api exposes StyledTemplateLanguageService directly to third-party consumers who may + * reasonably hold and mutate a returned CompletionInfo (appending an entry, renaming one, + * sorting the list). A cache hit returns a fresh CompletionInfo with a fresh entries array of + * shallow-copied entries every time, so mutating one caller's result never reaches the cached + * original or a later caller's result at the same position. + */ + const service = createService() + const context = createContext('color:') + const position = context.toPosition(context.text.length) + + /** Populates the cache; the mutated result below comes from a cache hit, not this miss. */ + service.getCompletionsAtPosition(context, position) + + const first = service.getCompletionsAtPosition({ ...context }, position) + const firstEntry = first.entries[0] + assert.isDefined(firstEntry) + const originalEntryCount = first.entries.length + const originalFirstEntryName = firstEntry.name + + assert.doesNotThrow(() => { + first.entries.push({ + name: 'injected', + kind: ts.ScriptElementKind.unknown, + sortText: 'injected', + }) + firstEntry.name = 'renamed' + }) + + const second = service.getCompletionsAtPosition({ ...context }, position) + assert.notStrictEqual(second, first) + assert.strictEqual(second.entries.length, originalEntryCount) + assert.isUndefined(second.entries.find((entry) => entry.name === 'injected')) + assert.strictEqual(second.entries[0]?.name, originalFirstEntryName) + }) + + it('should return a fresh empty completion result per call, not a shared mutable object', () => { + /** + * ./api exposes StyledTemplateLanguageService directly to third-party consumers, who may + * reasonably hold onto and mutate a returned CompletionInfo (appending their own entries, + * for example): a shared "empty" object returned by reference to every caller would let one + * consumer's mutation corrupt every other empty-template completion result for the rest of + * the process. + */ + const service = createService() + const context = createContext('/* a */') + const insideComment = context.toPosition('/* a'.length) + + const first = service.getCompletionsAtPosition(context, insideComment) + first.entries.push({ + name: 'injected', + kind: ts.ScriptElementKind.unknown, + sortText: 'injected', + }) + const second = service.getCompletionsAtPosition(context, insideComment) + + assert.deepEqual(second.entries, []) + }) + + it('should skip the language services for hover in an empty template', () => { + const factory = createFakeLanguageServiceFactory() + const virtualDocumentProvider = new StyledVirtualDocumentProvider(ts) + const createVirtualDocument = vi.spyOn(virtualDocumentProvider, 'createVirtualDocument') + const service = createServiceWithFactory(factory, { virtualDocumentProvider }) + const context = createContext('') + + assert.isUndefined(service.getQuickInfoAtPosition(context, { line: 0, character: 0 })) + assert.strictEqual(createVirtualDocument.mock.calls.length, 0) + assert.strictEqual(factory.parsedDocuments.length, 0) + assert.strictEqual(factory.hoverRequests, 0) + }) + + it('should skip the CSS language services for completions inside a comment', () => { + const factory = createFakeLanguageServiceFactory([{ label: 'css-item' }]) + const noEmmet: EmmetCompletionProvider = { doComplete: () => undefined } + const service = createServiceWithFactory(factory, { emmetCompletionProvider: noEmmet }) + const comment = createContext('/* a */') + const code = createContext('a') + + assert.deepEqual( + service.getCompletionsAtPosition(comment, comment.toPosition('/* a'.length)).entries, + [], + ) + assert.strictEqual(factory.completionRequests, 0) + assert.deepEqual( + service + .getCompletionsAtPosition(code, code.toPosition(code.text.length)) + .entries.map((entry) => entry.name), + ['css-item'], + ) + }) + + it('should keep interactive requests isolated from diagnostics and code actions', () => { + const factory = createFakeLanguageServiceFactory() + const service = createServiceWithFactory(factory) + const context = createContext('color:') + + service.getCompletionsAtPosition(context, context.toPosition(context.text.length)) + service.getQuickInfoAtPosition(context, context.toPosition(1)) + + assert.strictEqual(factory.validationRequests, 0) + assert.strictEqual(factory.codeActionRequests, 0) + }) + + describe('exception recovery', () => { + /** + * An uncaught exception here would fail the whole tsserver response for the file, including + * TypeScript's own diagnostics. Each test replaces one lazily built feature getter (a + * private accessor at runtime, like any other TypeScript "private" member) with one that + * throws, isolating which entry point's recovery is under test. + */ + it.each< + [ + method: string, + getter: string, + request: (service: StyledTemplateLanguageService, context: TemplateContext) => unknown, + fallback: unknown, + ] + >([ + [ + 'getCompletionsAtPosition', + 'completions', + (service, context) => service.getCompletionsAtPosition(context, context.toPosition(0)), + { + entries: [], + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + metadata: { isIncomplete: false }, + }, + ], + [ + 'getCompletionEntryDetails', + 'completions', + (service, context) => + service.getCompletionEntryDetails(context, context.toPosition(0), 'aliceblue'), + { + displayParts: [{ kind: 'text', text: 'aliceblue' }], + documentation: [], + kind: ts.ScriptElementKind.unknown, + kindModifiers: '', + name: 'aliceblue', + tags: [], + }, + ], + [ + 'getQuickInfoAtPosition', + 'hover', + (service, context) => service.getQuickInfoAtPosition(context, context.toPosition(0)), + undefined, + ], + [ + 'getSemanticDiagnostics', + 'diagnostics', + (service, context) => service.getSemanticDiagnostics(context), + [], + ], + [ + 'getCodeFixesAtPosition', + 'codeActions', + (service, context) => service.getCodeFixesAtPosition(context, 0, 1), + [], + ], + [ + 'getOutliningSpans', + 'folding', + (service, context) => service.getOutliningSpans(context), + [], + ], + ])( + 'should recover from %s throwing and return its fallback', + (method, getter, request, fallback) => { + const logger = { log: vi.fn() } + const service = new StyledTemplateLanguageService( + ts, + new PluginConfigurationManager(), + new StyledVirtualDocumentProvider(ts), + logger, + ) + Object.defineProperty(service, getter, { + get() { + throw new Error(`${getter} feature construction failed`) + }, + }) + + assert.deepEqual(request(service, createContext('color: red;')), fallback) + assert.strictEqual(logger.log.mock.calls.length, 1) + assert.match( + logger.log.mock.calls[0]?.[0], + new RegExp(`^${method} threw and was recovered`), + ) + }, + ) + + it('should still recover when no logger was supplied', () => { + const service = new StyledTemplateLanguageService( + ts, + new PluginConfigurationManager(), + new StyledVirtualDocumentProvider(ts), + ) + Object.defineProperty(service, 'diagnostics', { + get() { + throw new Error('diagnostics feature construction failed') + }, + }) + const context = createContext('color: red;') + + let result: ts.Diagnostic[] | undefined + assert.doesNotThrow(() => { + result = service.getSemanticDiagnostics(context) + }) + assert.deepEqual(result, []) + }) + + it('should keep working on a later request after an earlier one threw', () => { + /** + * A recovered exception must not leave the service (or a lazily built feature instance) in + * a broken state. Reproduced here against the real CSS language service, not a fake, using + * the exact virtual-document text template-substitutions.ts produces for a placeholder used + * as a property name and a placeholder used as its value, for example + * `` `${prop}: ${value};` `` (the fake property fill is "$a" plus x-fill, + * template-substitutions.ts, so the virtual text becomes "$axxxxx: xxxxxxxx;"): a + * completions request with the caret at the very start of that fake property throws inside + * vscode-css-languageservice's own property-completion code (declaration.getProperty is not + * a function, cssCompletion.js), so this also stands as the regression test for that + * specific upstream defect. createContext (create-template-context.ts) does not run + * substitution (unlike the real decorator), so the already-substituted shape is passed as both + * context.text and context.rawText, matching what the real pipeline would hand this class. + */ + const logger = { log: vi.fn() } + const service = new StyledTemplateLanguageService( + ts, + new PluginConfigurationManager(), + new StyledVirtualDocumentProvider(ts), + logger, + ) + const throwingContext = createContext('\n $axxxxx: xxxxxxxx;\n ') + + const firstResult = service.getCompletionsAtPosition( + throwingContext, + throwingContext.toPosition(3), + ) + assert.deepEqual(firstResult.entries, []) + assert.strictEqual(logger.log.mock.calls.length, 1) + assert.match(logger.log.mock.calls[0]?.[0], /getCompletionsAtPosition threw/) + + const laterContext = createContext('color:') + const laterResult = service.getCompletionsAtPosition( + laterContext, + laterContext.toPosition(laterContext.text.length), + ) + assert.isTrue(laterResult.entries.some((entry) => entry.name === 'aliceblue')) + }) + }) +}) + +/** + * A context for `rawText` shaped like the decorator's StandardTemplateContext: `text` is a lazy + * getter, memoized per context, that substitutes the placeholders on its first read, counted in + * `counter`. The placeholder spans are derived from the template node the way the decorator + * derives them (PlaceholderSubstituter.getPlaceholderSpans). + */ +function createSubstitutingContext( + rawText: string, + tagName: string, + counter: { count: number }, +): TemplateContext { + const base = createContext(rawText, tagName) + const spans: Array<{ end: number; start: number }> = [] + const node = base.node + if (ts.isTemplateExpression(node)) { + const stringStart = node.getStart() + 1 + let spanStart = node.head.end - stringStart - 2 + for (const { literal } of node.templateSpans) { + spans.push({ end: literal.getStart() - stringStart + 1, start: spanStart }) + spanStart = literal.getEnd() - stringStart - 2 + } + } + let text: string | undefined + return { + fileName: base.fileName, + node, + rawText: base.rawText, + get text() { + if (text === undefined) { + counter.count++ + text = getTemplateSubstitutions(base.rawText, spans) + } + return text + }, + toOffset: base.toOffset, + toPosition: base.toPosition, + typescript: base.typescript, + } +} + +function createService() { + return new StyledTemplateLanguageService( + ts, + new PluginConfigurationManager(), + new StyledVirtualDocumentProvider(ts), + ) +} + +interface ServiceOptions { + readonly configurationManager?: PluginConfigurationManager + readonly emmetCompletionProvider?: EmmetCompletionProvider + readonly virtualDocumentProvider?: VirtualDocumentProvider +} + +function createServiceWithFactory( + factory: StylesLanguageServiceFactory, + { + configurationManager = new PluginConfigurationManager(), + emmetCompletionProvider, + virtualDocumentProvider = new StyledVirtualDocumentProvider(ts), + }: ServiceOptions = {}, +) { + return new StyledTemplateLanguageService( + ts, + configurationManager, + virtualDocumentProvider, + factory, + emmetCompletionProvider, + ) +} + +function createServiceWithCompletionItems( + completionItems: CompletionItem[] | ((document: TextDocument) => CompletionItem[]), + virtualDocumentProvider?: VirtualDocumentProvider, +) { + return createServiceWithFactory(createFakeLanguageServiceFactory(completionItems), { + virtualDocumentProvider, + }) +} + +function createCustomVirtualDocumentProvider( + prefix: string, + suffix: string, +): VirtualDocumentProvider { + return { + createVirtualDocument(context) { + return TextDocument.create( + 'untitled://custom.scss', + 'scss', + 1, + `${prefix}${context.text}${suffix}`, + ) + }, + toVirtualDocPosition(position) { + return { line: position.line, character: position.character + prefix.length } + }, + fromVirtualDocPosition(position) { + return { line: position.line, character: position.character - prefix.length } + }, + toVirtualDocOffset(offset) { + return offset + prefix.length + }, + fromVirtualDocOffset(offset) { + return offset - prefix.length + }, + getVirtualDocumentWrapper() { + return prefix + }, + } +} + +/** + * A provider whose offset mapping is not a fixed shift: its virtual document inserts `marker` + * right before raw offset `insertAt`, so an offset before the insertion shifts by only + * `prefix.length`, and one at or after it shifts by `prefix.length + marker.length`. Exercises + * translateCompletionListToCompletionInfo's non-shift-mapped branch (completions.ts), which must + * call toVirtualDocOffset/fromVirtualDocOffset per offset instead of subtracting one constant. + */ +function createNonShiftVirtualDocumentProvider({ + insertAt, + marker, + prefix, +}: { + insertAt: number + marker: string + prefix: string +}): VirtualDocumentProvider { + const toVirtual = (offset: number) => + offset < insertAt ? offset + prefix.length : offset + prefix.length + marker.length + const fromVirtual = (offset: number) => + offset < insertAt + prefix.length + ? offset - prefix.length + : offset - prefix.length - marker.length + + return { + createVirtualDocument(context) { + return TextDocument.create( + 'untitled://custom-non-shift.scss', + 'scss', + 1, + `${prefix}${context.rawText.slice(0, insertAt)}${marker}${context.rawText.slice(insertAt)}`, + ) + }, + toVirtualDocPosition(position) { + return { line: position.line, character: toVirtual(position.character) } + }, + fromVirtualDocPosition(position) { + return { line: position.line, character: fromVirtual(position.character) } + }, + toVirtualDocOffset(offset) { + return toVirtual(offset) + }, + fromVirtualDocOffset(offset) { + return fromVirtual(offset) + }, + getVirtualDocumentWrapper() { + return prefix + }, + } +} + +function createServiceWithLanguageServiceResponses(responses: FakeLanguageServiceResponses) { + return createServiceWithFactory(createFakeLanguageServiceFactory([], responses)) +} + +const RENAME_TO_BORDER = createRenameCodeAction({ + end: 7, + newText: 'border', + start: 0, + title: "Rename to 'border'", +}) + +/** A diagnostic on the first template line (virtual line 1) from `start` to `end`. */ +function createLineOneDiagnostic(start: number, end: number): Diagnostic { + return { + message: 'fixable', + range: { end: { line: 1, character: end }, start: { line: 1, character: start } }, + } +} + +/** A CSS code action with one edit on the first template line, the shape the CSS service emits. */ +function createRenameCodeAction({ + end, + newText, + start, + title, +}: { + end: number + newText: string + start: number + title: string +}): Command { + return { + arguments: [ + undefined, + undefined, + [ + { + newText, + range: { end: { line: 1, character: end }, start: { line: 1, character: start } }, + }, + ], + ], + command: CSS_APPLY_CODE_ACTION_COMMAND, + title, + } +} + +function createCompletionItem(label: string, range: Range): CompletionItem { + return { + label, + textEdit: { range, newText: label }, + } +} + +function createFakeLanguageServiceFactory( + completionItems: CompletionItem[] | ((document: TextDocument) => CompletionItem[]) = [], + responses: FakeLanguageServiceResponses = {}, +): StylesLanguageServiceFactory & { + readonly codeActionRequests: number + readonly completionRequests: number + cssConfigurations: LanguageSettings[] + readonly hoverRequests: number + parsedDocuments: TextDocument[] + scssConfigurations: LanguageSettings[] + readonly validationRequests: number +} { + const cssConfigurations: LanguageSettings[] = [] + const scssConfigurations: LanguageSettings[] = [] + const parsedDocuments: TextDocument[] = [] + let completionRequests = 0 + let hoverRequests = 0 + let validationRequests = 0 + let codeActionRequests = 0 + const cssLanguageService: CssLanguageService = { + configure(configuration) { + if (configuration) { + cssConfigurations.push(configuration) + } + }, + doComplete(document) { + completionRequests++ + return { + isIncomplete: false, + items: typeof completionItems === 'function' ? completionItems(document) : completionItems, + } + }, + } + const scssLanguageService: ScssLanguageService = { + configure(configuration) { + if (configuration) { + scssConfigurations.push(configuration) + } + }, + parseStylesheet(document) { + parsedDocuments.push(document) + return {} + }, + doComplete() { + completionRequests++ + return responses.scssCompletions ?? { isIncomplete: false, items: [] } + }, + doHover() { + hoverRequests++ + return responses.hover ?? null + }, + doValidation(document) { + validationRequests++ + const { diagnostics = [] } = responses + return typeof diagnostics === 'function' ? diagnostics(document) : diagnostics + }, + /** Like the real service, offers actions only for the diagnostics it is asked about. */ + doCodeActions(_document, _range, context) { + codeActionRequests++ + return context.diagnostics.length > 0 ? (responses.codeActions ?? []) : [] + }, + getFoldingRanges() { + return responses.foldingRanges ?? [] + }, + } + + return { + cssConfigurations, + scssConfigurations, + parsedDocuments, + get completionRequests() { + return completionRequests + }, + get hoverRequests() { + return hoverRequests + }, + get validationRequests() { + return validationRequests + }, + get codeActionRequests() { + return codeActionRequests + }, + createCssLanguageService() { + return cssLanguageService + }, + createScssLanguageService() { + return scssLanguageService + }, + } +} + +interface FakeLanguageServiceResponses { + readonly codeActions?: ReturnType + readonly diagnostics?: + | ReturnType + | ((document: TextDocument) => ReturnType) + readonly foldingRanges?: ReturnType + readonly hover?: Exclude, null> + readonly scssCompletions?: ReturnType +} diff --git a/test/unit/template-substitutions.test.ts b/test/unit/template-substitutions.test.ts new file mode 100644 index 0000000..6ae79c9 --- /dev/null +++ b/test/unit/template-substitutions.test.ts @@ -0,0 +1,1188 @@ +import { assert, describe, it } from 'vitest' + +import { getTemplateSubstitutions } from '../../src/template/template-substitutions' + +describe('substituter', () => { + it('should replace property value with x', () => { + assert.deepEqual( + performSubstitutions(['width: 1px;', `color: \${'red'};`, 'color: red;'].join('\n')), + ['width: 1px;', `color: xxxxxxxx;`, 'color: red;'].join('\n'), + ) + }) + + it('should insert whitespace when placeholder is used an entire property', () => { + assert.deepEqual( + performSubstitutions(['width: 1px;', `\${'color: red;'}`, 'color: red;'].join('\n')), + ['width: 1px;', ` `, 'color: red;'].join('\n'), + ) + }) + + it('should insert a false property when placeholder is used an entire property with trailing semi-colon', () => { + assert.deepEqual( + performSubstitutions(['width: 1px;', `\${'color: red'};`, 'color: red;'].join('\n')), + ['width: 1px;', `$a:0 ;`, 'color: red;'].join('\n'), + ) + }) + + it('should add a zero for percent units', () => { + assert.deepEqual(performSubstitutions('width: ${10}%;'), 'width: 00000%;') + }) + + it('should replace an empty placeholder on the final line without a trailing newline', () => { + assert.deepEqual(performSubstitutions('color: ${}'), 'color: xxx') + }) + + it('should preserve length for an empty placeholder used as an entire declaration', () => { + const value = '${};' + const result = performSubstitutions(value) + + assert.strictEqual(result, 'a:0;') + assert.strictEqual(result.length, value.length) + }) + + it('should replace property with fake property when placeholder is used in name (#52)', () => { + assert.deepEqual( + performSubstitutions(['width: 1px;', `\${123}: 1px;`, 'color: red;'].join('\n')), + ['width: 1px;', `$axxxx: 1px;`, 'color: red;'].join('\n'), + ) + }) + + it('should insert x for placeholder used as rule', () => { + assert.deepEqual( + performSubstitutions(['${"button"} {', 'color: ${"red"};', '}'].join('\n')), + ['xxxxxxxxxxx {', 'color: xxxxxxxx;', '}'].join('\n'), + ) + }) + + it('should insert x for placeholder used as part of a rule (#59)', () => { + assert.deepEqual( + performSubstitutions(['${"button"}, ${"a"} {', 'color: ${"red"};', '}'].join('\n')), + ['xxxxxxxxxxx, xxxxxx {', 'color: xxxxxxxx;', '}'].join('\n'), + ) + }) + + it('should fake out property name when inside nested rule (#54)', () => { + assert.deepEqual( + performSubstitutions( + [ + '&.buu-foo {', + ' ${"baseShape"};', + ' &.active {', + ' font-size: 2rem;', + ' }', + '}', + ].join('\n'), + ), + ['&.buu-foo {', ' $a:0 ;', ' &.active {', ' font-size: 2rem;', ' }', '}'].join( + '\n', + ), + ) + }) + + it('should add zeros for color units (#60)', () => { + assert.deepEqual(performSubstitutions('color: #${1};'), 'color: #000 ;') + }) + + it.each([ + ['an empty hex placeholder', '${}'], + ['a short hex placeholder', '${a}'], + ['a long hex placeholder', '${longValue}'], + ])('should preserve length for %s', (_description, placeholder) => { + assert.deepEqual( + performSubstitutions(`color: #${placeholder};`), + `color: #000${' '.repeat(Math.max(placeholder.length - 3, 0))};`, + ) + }) + + it('should replace adjacent variables with x (#62)', () => { + assert.deepEqual( + performSubstitutions( + [`margin: \${'1px'}\${'1px'};`, `padding: \${'1px'} \${'1px'};`].join('\n'), + ), + [`margin: xxxxxxxxxxxxxxxx;`, `padding: xxxxxxxx xxxxxxxx;`].join('\n'), + ) + }) + + it('should replace placeholder that spans multiple lines with x (#44)', () => { + assert.deepEqual( + performSubstitutions(['background:', ` $\{'transparent'};`].join('\n')), + ['background:', ' xxxxxxxxxxxxxxxx;'].join('\n'), + ) + }) + + it('should preserve line breaks inside a multiline placeholder', () => { + const value = ['color: ${', ' color', '};'].join('\n') + + assert.deepEqual( + getTemplateSubstitutions(value, [ + { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, + ]), + ['color: xx', 'xxxxxxx', 'x;'].join('\n'), + ) + }) + + it('should preserve CRLF line endings inside a multiline placeholder', () => { + const value = ['color: ${', ' color', '};'].join('\r\n') + + assert.deepEqual( + getTemplateSubstitutions(value, [ + { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, + ]), + ['color: xx', 'xxxxxxx', 'x;'].join('\r\n'), + ) + }) + + it.each([ + ['line separator', '\u2028'], + ['paragraph separator', '\u2029'], + ])('should preserve the Unicode %s inside a multiline placeholder', (_description, separator) => { + const value = `color: \${${separator} color${separator}};` + + const result = getTemplateSubstitutions(value, [ + { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, + ]) + + assert.strictEqual(result, `color: xx${separator}xxxxxxx${separator}x;`) + assert.strictEqual(result.length, value.length) + }) + + it('should substitute placeholders after a multiline placeholder in their own context', () => { + const value = ['color: ${', ' color', '};', 'width: ${10}%;'].join('\n') + const firstStart = value.indexOf('${') + const firstEnd = value.indexOf('}') + 1 + const secondStart = value.indexOf('${', firstEnd) + + assert.deepEqual( + getTemplateSubstitutions(value, [ + { start: firstStart, end: firstEnd }, + { start: secondStart, end: value.indexOf('}', secondStart) + 1 }, + ]), + ['color: xx', 'xxxxxxx', 'x;', 'width: 00000%;'].join('\n'), + ) + }) + + it.each([ + ['pseudo class', ':not(:first-child) {'], + ['pseudo element', '::before {'], + ])('should replace a component selector followed by a %s', (_description, suffix) => { + const placeholder = '${Component}' + assert.deepEqual( + performSubstitutions(`${placeholder}${suffix}`), + `&${' '.repeat(placeholder.length - 1)}${suffix}`, + ) + }) + + it.each([ + '--${name}', + '--theme-${name}', + '--${namespace}-${name}', + ':root { --${name}', + 'color: red; --${name}', + '--føø-${name}', + '--\\66 oo-${name}', + ])('should replace an interpolation in the custom property name %s', (propertyName) => { + assert.deepEqual( + performSubstitutions(`${propertyName}: 1px;`), + `${propertyName.replace(/\$\{[^}]*\}/g, (placeholder) => 'x'.repeat(placeholder.length))}: 1px;`, + ) + }) + + it('should replace dynamic declaration names followed by dynamic values (#25)', () => { + const propertyName = '${varName}' + const propertyValue = '${value}' + + assert.deepEqual( + performSubstitutions(`${propertyName}: ${propertyValue};`), + `$a${'x'.repeat(propertyName.length - 2)}: ${'x'.repeat(propertyValue.length)};`, + ) + }) + + it('should preserve CRLF line endings for dynamic declaration names and values (#25)', () => { + const value = ['${varName}: ${', ' value', '};'].join('\r\n') + + assert.deepEqual(performSubstitutions(value), ['$axxxxxxxx: xx', 'xxxxxxx', 'x;'].join('\r\n')) + }) + + it('should bound syntax masking to the template text', () => { + const value = '${name}: 1px;' + const result = getTemplateSubstitutions(value, [{ start: 0, end: Number.MAX_SAFE_INTEGER }]) + + assert.strictEqual(result, ' '.repeat(value.length)) + assert.strictEqual(result.length, value.length) + }) + + /** The published ./api getTemplateSettings().getSubstitutions accepts any span list. */ + it('should substitute spans given out of order as it does the same spans in order', () => { + const value = 'color: ${a}; margin: ${b};' + const spans = getSpans(value) + + assert.strictEqual( + getTemplateSubstitutions(value, spans.slice().reverse()), + getTemplateSubstitutions(value, spans), + ) + assert.strictEqual(getTemplateSubstitutions(value, spans), 'color: xxxx; margin: xxxx;') + }) + + it.each([ + [ + 'overlapping spans, read as their union', + [ + { start: 7, end: 10 }, + { start: 8, end: 11 }, + ], + ], + [ + 'a span inside another', + [ + { start: 7, end: 11 }, + { start: 8, end: 9 }, + ], + ], + ['a span starting before the text', [{ start: -3, end: 11 }]], + [ + 'a span starting past the text', + [ + { start: 7, end: 11 }, + { start: 40, end: 50 }, + ], + ], + ])('should keep the text length for %s', (_description, spans) => { + const value = 'color: ${a}; margin: 0;' + const result = getTemplateSubstitutions(value, spans) + + assert.strictEqual(result.length, value.length) + assert.strictEqual(result.slice(11), value.slice(11)) + }) + + /** Spans shorter than a branch's fill, as `./api`'s getSubstitutions accepts them. */ + it.each([ + ['an empty span in selector position', ' :hover {}', { start: 1, end: 1 }, ' :hover {}'], + ['a one-character property name', 'a: 1; b:red', { start: 6, end: 7 }, 'a: 1; x:red'], + ['a one-character hex color', 'color: #a;', { start: 8, end: 9 }, 'color: #x;'], + ['a one-character mixin before ";"', 'a;\nb;', { start: 3, end: 4 }, 'a;\n ;'], + ])('should fall back to a plain fill for %s', (_description, value, span, expected) => { + assert.strictEqual(getTemplateSubstitutions(value, [span]), expected) + }) + + it.each([ + ['a mixin before ";"', '', ';'], + ['a property name', '', ': 1px;'], + ['a property name joined to a name', 'padding-', ': 1px;'], + ['a selector', '', ':hover & {'], + ['a hex color', 'color: #', ';'], + ['an @media condition', '@media screen and ', ' {'], + ['an @supports condition', '@supports ', ' {'], + ])( + 'should keep the text length for %s placeholder of every short length', + (_description, before, after) => { + for (let length = 0; length <= 6; length++) { + const value = `${before}${'y'.repeat(length)}${after}` + const span = { start: before.length, end: before.length + length } + + assert.strictEqual(getTemplateSubstitutions(value, [span]).length, value.length) + } + }, + ) + + it('should preserve UTF-16 length for substitutions containing surrogate pairs', () => { + const value = '${"😀"}: ${value};' + const result = performSubstitutions(value) + + assert.strictEqual(result.length, value.length) + assert.strictEqual(result, '$axxxxx: xxxxxxxx;') + }) + + it('should preserve template text when there are no substitutions', () => { + const value = 'color: red; 😀' + + assert.strictEqual(getTemplateSubstitutions(value, []), value) + }) + + it('should preserve dynamic pseudo selectors after component interpolations', () => { + const component = '${Component}' + const pseudo = '${pseudo}' + + assert.deepEqual( + performSubstitutions(`${component}:${pseudo} { color: red; }`), + `&${' '.repeat(component.length - 1)}:${'x'.repeat(pseudo.length)} { color: red; }`, + ) + }) + + it.each([ + ['with spaces before its semicolon', ' ;'], + ['with a semicolon on the next line', '\n;'], + ])('should use a dummy property for a mixin %s', (_description, suffix) => { + const placeholder = '${mixin}' + assert.deepEqual( + performSubstitutions(`${placeholder}${suffix}`), + `$a:0${' '.repeat(placeholder.length - 4)}${suffix}`, + ) + }) + + it('should replace placeholder used in contextual selector (#71)', () => { + assert.deepEqual( + performSubstitutions( + [ + 'position: relative;', + '', + '${FlipContainer}:hover & {', + ' transform: rotateY(180deg);', + '}', + ].join('\n'), + ), + [ + 'position: relative;', + '', + '& :hover & {', + ' transform: rotateY(180deg);', + '}', + ].join('\n'), + ) + }) + + it('should keep every line terminator of a multi-line placeholder used as a mixin', () => { + /** The dummy declaration is written around the line break, which the parser reads as whitespace. */ + const value = ['${', ' m', '};'].join('\n') + + assert.strictEqual(performSubstitutions(value), '$a\n:0 \n ;') + }) + + it('should keep every line terminator of a multi-line placeholder used as a selector', () => { + const value = ['${', ' B', '}:hover & {', ' color: red;', '}'].join('\n') + + assert.strictEqual( + performSubstitutions(value), + ['& ', ' ', ' :hover & {', ' color: red;', '}'].join('\n'), + ) + }) + + it.each([ + /** "000" stays on one line to read as a hex color, so it covers a line break among the first three characters. */ + ['right after "${"', ['color: #${', ' x', '};'], 'color: #000 \n ;'], + ['after the first line', ['color: #${x', ' }', ';'], 'color: #000\n \n;'], + ])( + 'should keep the line terminators of a multi-line hex color placeholder broken %s past its "000"', + (_description, lines, expected) => { + assert.strictEqual(performSubstitutions(lines.join('\n')), expected) + }, + ) + + it.each([ + ['line feed', '\n'], + ['carriage return + line feed', '\r\n'], + ['carriage return', '\r'], + ['line separator', '
'], + ['paragraph separator', '
'], + ])( + 'should treat a placeholder followed by a colon and a %s as a property name, not a selector, when a rule body follows on a later line', + (_description, lineTerminator) => { + /** + * A line terminator right after the colon makes a property name ("$a" prefix), never a + * selector ("&" fill), even though a "{" appears on the next line. + */ + const value = `\${A}:${lineTerminator}a{` + + const result = getTemplateSubstitutions(value, [ + { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, + ]) + + assert.strictEqual(result, `$axx:${lineTerminator}a{`) + assert.strictEqual(result.length, value.length) + }, + ) + + it('should still treat a placeholder followed by a colon and a rule body as a selector when nothing but ordinary characters separates them', () => { + const value = '${A}: a{' + + const result = getTemplateSubstitutions(value, [ + { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, + ]) + + assert.strictEqual(result, '& : a{') + assert.strictEqual(result.length, value.length) + }) + + it.each([ + ['after a declaration on the same line', 'color: red; ${mixin}\nmargin: 0;'], + ['inside a one-line rule body', '&:hover { ${mixin} }\nmargin: 0;'], + ['after a closing brace on the same line', 'a { color: red; } ${mixin}\nmargin: 0;'], + ['twice on one line after a declaration line', 'color: red;\n${a} ${b}\nmargin: 0;'], + ['at the template start before a declaration', '${mixin} color: red;'], + ['after a declaration and a block comment', 'color: red; /* note */ ${mixin}'], + ['after a block comment at the template start', '/* note */ ${mixin}'], + /** + * The first placeholder is placed by the line rule; the second follows it, and the statement + * rule reaches past the line comment to the ";" for the first, which passes block position on. + */ + ['twice on one line after a declaration and a line comment', 'color: red; // note\n${a} ${b}'], + ])('should fill a block-position placeholder %s with whitespace', (_description, value) => { + assert.strictEqual( + performSubstitutions(value), + value.replace(/\$\{[^}]*\}/g, (placeholder) => ' '.repeat(placeholder.length)), + ) + }) + + it('should use a dummy declaration for a second mixin on one line followed by a semicolon', () => { + const value = '${rule1} ${rule2};' + + assert.strictEqual(performSubstitutions(value), `${' '.repeat(8)} $a:0${' '.repeat(4)};`) + }) + + it('should use a dummy declaration for a mixin followed by a semicolon after a declaration on the same line', () => { + const value = 'width: 1px; ${mixin};' + + assert.strictEqual(performSubstitutions(value), `width: 1px; $a:0${' '.repeat(4)};`) + }) + + it('should use a dummy declaration for a mixin followed by a semicolon after a declaration and a block comment', () => { + const value = 'color: red; /* note */ ${mixin};' + + assert.strictEqual(performSubstitutions(value), `color: red; /* note */ $a:0${' '.repeat(4)};`) + }) + + it('should fill a placeholder after a comment that holds another placeholder as a block, and the one inside the comment as a value', () => { + const value = 'color: red; /* ${a} */ ${b}' + + assert.strictEqual(performSubstitutions(value), `color: red; /* xxxx */ ${' '.repeat(4)}`) + }) + + it('should x-fill a placeholder inside a string even after a ";" in that string', () => { + const value = 'content: "a; ${b}";' + + assert.strictEqual(performSubstitutions(value), 'content: "a; xxxx";') + }) + + it('should fill a mixin after a declaration whose value holds escaped quotes with whitespace', () => { + /** The escaped quotes open no string, so the ";" before the mixin ends a statement. */ + const value = 'content: \\"x\\"; ${mixin}' + + assert.strictEqual(performSubstitutions(value), `content: \\"x\\"; ${' '.repeat(8)}`) + }) + + it('should x-fill a placeholder after a CSS-escaped ";", which ends no statement', () => { + /** The raw `\\` cooks to one backslash, which escapes the ";" in CSS. */ + const value = 'a\\\\; ${b}' + + assert.strictEqual(performSubstitutions(value), 'a\\\\; xxxx') + }) + + /** + * Each raw backslash below is a JavaScript escape, which styled-components receives cooked, so + * the substitution reads the character it stands for. + */ + it.each([ + [ + 'a string closed by a JavaScript-escaped quote', + 'content: "\\"; ${mixin};', + 'content: "\\"; $a:0 ;', + ], + ['a JavaScript-escaped ";"', 'color: red\\; ${mixin};', 'color: red\\; $a:0 ;'], + ['a JavaScript-escaped "{"', '&:hover \\{ ${mixin}; }', '&:hover \\{ $a:0 ; }'], + [ + 'a declaration, when a JavaScript-escaped ";" follows it', + 'color: red; ${mixin}\\;', + 'color: red; $a:0 \\;', + ], + ])('should read a mixin after %s as a dummy declaration', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it('should keep a value placeholder after a line-start placeholder in value position', () => { + /** + * The first placeholder is alone on its line (line rule) but continues the `padding:` value, + * so it never passes block position on: the second stays an x-filled value. + */ + const value = 'padding:\n ${a} ${b};' + + assert.strictEqual(performSubstitutions(value), `padding:\n ${' '.repeat(4)} xxxx;`) + }) + + it.each([ + ['a compound selector', '.active { color: blue; }'], + ['a parent selector', ' & { color: blue; }'], + ['a child combinator', ' > span { color: blue; }'], + ['an attribute selector', '[disabled] { color: blue; }'], + ['a universal selector', ' * { color: blue; }'], + ['an adjacent sibling combinator', ' + a { color: blue; }'], + ['a general sibling combinator', ' ~ a { color: blue; }'], + ['an id selector', '#id { color: blue; }'], + ['a rule body', ' { color: blue; }'], + ['a selector list', ', a { color: blue; }'], + ])( + 'should keep a placeholder after a statement boundary x-filled when %s follows it', + (_description, suffix) => { + const value = `color: red; \${Child}${suffix}` + + assert.strictEqual(performSubstitutions(value), `color: red; ${'x'.repeat(8)}${suffix}`) + }, + ) + + it('should skip a comment between a placeholder and the "{" that follows it, keeping it x-filled the same as without the comment', () => { + const control = 'color: red; ${Child} { color: blue; }' + const withComment = 'color: red; ${Child} /* note */ { color: blue; }' + + assert.strictEqual( + performSubstitutions(control), + `color: red; ${'x'.repeat(8)} { color: blue; }`, + ) + assert.strictEqual( + performSubstitutions(withComment), + `color: red; ${'x'.repeat(8)} /* note */ { color: blue; }`, + ) + }) + + it('should treat a string between a placeholder and the "{" that follows it as significant, not skip it like a comment', () => { + const value = 'color: red; ${Child} "note" { color: blue; }' + + assert.strictEqual( + performSubstitutions(value), + `color: red; ${' '.repeat(8)} "note" { color: blue; }`, + ) + }) + + it('should not treat a boundary character inside a placeholder as a statement boundary', () => { + const value = "color: ${';'} ${b}\nmargin: 0;" + + assert.strictEqual(performSubstitutions(value), `color: ${'x'.repeat(6)} xxxx\nmargin: 0;`) + }) + + it.each([ + ['after a name prefix', 'padding-${s}: 4px;', 'padding-#{x}: 4px;'], + ['after a longer name prefix', 'margin-${side}: 4px;', 'margin-#{x }: 4px;'], + ['between name parts', 'border-${side}-color: red;', 'border-#{x }-color: red;'], + ['before a name suffix', '${side}-top: 0;', '#{x }-top: 0;'], + ['before a short name suffix', '${s}-width: 4px;', '#{x}-width: 4px;'], + ['twice in one name', '${a}-${b}: 1px;', '#{x}-#{x}: 1px;'], + ['twice adjacent in one name', '${a}${b}-x: 1px;', '#{x}#{x}-x: 1px;'], + [ + 'after a declaration on the same line', + 'color: red; ${side}-top: 0;', + 'color: red; #{x }-top: 0;', + ], + ['with whitespace before the colon', 'padding-${s} : 4px;', 'padding-#{x} : 4px;'], + ['on its own line', 'color: red;\n${side}-top: 0;', 'color: red;\n#{x }-top: 0;'], + [ + 'after a name prefix, before "&" later on the line', + 'padding-${s}: 0; &:hover { color: red; }', + 'padding-#{x}: 0; &:hover { color: red; }', + ], + [ + 'before a name suffix, before "&" later on the line', + '${s}-top: 0; &:hover { color: red; }', + '#{x}-top: 0; &:hover { color: red; }', + ], + [ + 'between name parts, before "&" later on the line', + 'border-${s}-color: red; &:hover { color: red; }', + 'border-#{x}-color: red; &:hover { color: red; }', + ], + [ + 'after a name prefix, before "{" later on the line', + 'padding-${s}: 0; a { color: red; }', + 'padding-#{x}: 0; a { color: red; }', + ], + ])( + 'should read a placeholder joined to a property name %s as a Sass interpolation', + (_description, value, expected) => { + const result = performSubstitutions(value) + + assert.strictEqual(result, expected) + assert.strictEqual(result.length, value.length) + }, + ) + + it.each([ + ['";" then "&"', '${p}: 0; &:hover { color: red; }', '$axx: 0; &:hover { color: red; }'], + ['";" then "{"', '${p}: 0; a { color: red; }', '$axx: 0; a { color: red; }'], + [ + '"}" then "&"', + '&:focus { ${p}: 0 } &:hover { color: red; }', + '&:focus { $axx: 0 } &:hover { color: red; }', + ], + ])( + 'should read a whole property name placeholder as a property name when %s follow on its line', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + ['"&" after a pseudo-class', '${B}:hover & { color: red; }', '& :hover & { color: red; }'], + [ + 'a pseudo-class function before "{"', + '${C}:not(.a) { color: red; }', + '& :not(.a) { color: red; }', + ], + ['a later "{" after "&:hover"', '&:hover ${C} { color: red; }', '&:hover xxxx { color: red; }'], + ])('should keep a selector placeholder followed by %s', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it('should read a non-ASCII space after a placeholder as a name character, as the CSS scanner does', () => { + const value = '${a} b: 1px;' + + assert.strictEqual(performSubstitutions(value), '#{x} b: 1px;') + }) + + it('should keep the line terminators of a multi-line placeholder joined to a property name', () => { + const value = 'padding-${\n s\n}: 4px;' + + assert.strictEqual(performSubstitutions(value), 'padding-#{\nx \n}: 4px;') + }) + + it('should x-fill a placeholder joined to a custom property name, before or after', () => { + assert.strictEqual(performSubstitutions('--${a}-b: 1px;'), '--xxxx-b: 1px;') + assert.strictEqual(performSubstitutions('--x-${a}: 1px;'), '--x-xxxx: 1px;') + }) + + it.each([ + ['a value after a name prefix', 'transition: padding-${s} 1s;', 'transition: padding-xxxx 1s;'], + ['a value before a unit', 'width: ${w}px;', 'width: xxxxpx;'], + ['a whole property name', '${prop}: 1px;', '$axxxxx: 1px;'], + [ + 'a mixin before a declaration on its own line', + '${mixin}\n-webkit-x: 1;', + ' \n-webkit-x: 1;', + ], + ])('should leave %s to its own branch', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it.each([ + ['after "and" in @media', '@media screen and ${q} {', '@media screen and (x )'], + ['after "not all and" in @media', '@media not all and ${q} {', '@media not all and (x )'], + ['after "or" in @media', '@media (a) or ${q} {', '@media (a) or (x )'], + [ + 'after "and" in a longer placeholder', + '@media screen and ${query} {', + '@media screen and (x )', + ], + ['in @media written in capitals', '@MEDIA screen AND ${q} {', '@MEDIA screen AND (x )'], + ['right after @supports', '@supports ${q} {', '@supports x( )'], + ['after "not" in @supports', '@supports not ${q} {', '@supports not x( )'], + [ + 'after "and" in @supports', + '@supports (display: grid) and ${q} {', + '@supports (display: grid) and x( )', + ], + ['after a container name', '@container card ${q} {', '@container card (x )'], + ['after "and" in @container', '@container card (a) and ${q} {', '@container card (a) and (x )'], + [ + 'after a container name that is a placeholder', + '@container ${n} ${q} {', + '@container xxxx (x )', + ], + [ + 'after a declaration on the same line', + 'color: red; @media screen and ${q} {', + 'color: red; @media screen and (x )', + ], + ['on its own line after "and"', '@media screen and\n ${q} {', '@media screen and\n (x )'], + [ + 'after "and" at the start of its own line', + '@media screen\n and ${q} {', + '@media screen\n and (x )', + ], + ['on its own line after @supports', '@supports\n ${q} {', '@supports\n x( )'], + [ + 'on its own line after a container name', + '@container card\n ${q} {', + '@container card\n (x )', + ], + [ + 'on its own line after "and" inside a rule', + '&:hover {\n @media screen and\n ${q} {', + '&:hover {\n @media screen and\n (x )', + ], + [ + 'after a declaration missing its semicolon on the line before', + 'color: red\n@media screen and ${q} {', + 'color: red\n@media screen and (x )', + ], + ['on both sides of "or" in @media', '@media ${q} or ${q} {', '@media (x ) or (x )'], + ['before "OR" in @media', '@media ${q} OR (a) {', '@media (x ) OR (a)'], + [ + 'in @media after a block comment', + '/* x */ @media screen and ${q} {', + '/* x */ @media screen and (x )', + ], + [ + 'in @media after a line comment on the line before a prelude split over lines', + 'color: red; // x\n @media screen and\n ${q} {', + 'color: red; // x\n @media screen and\n (x )', + ], + [ + 'after a comment between "and" and the placeholder', + '@media screen and /* x */ ${q} {', + '@media screen and /* x */ (x )', + ], + ])( + 'should fill an at-rule condition placeholder %s so it parses as a condition', + (_description, value, expectedPrefix) => { + const result = performSubstitutions(`${value} color: red; }`) + + assert.strictEqual(result, `${expectedPrefix} { color: red; }`) + }, + ) + + it('should keep the line terminators of a multi-line at-rule condition placeholder', () => { + const value = '@media screen and ${\n q\n} { color: red; }' + + assert.strictEqual(performSubstitutions(value), '@media screen and (x\n \n) { color: red; }') + }) + + it.each([ + [ + 'a media type right after @media', + '@media ${q} { color: red; }', + '@media xxxx { color: red; }', + ], + [ + 'a media type before "and"', + '@media ${q} and (max-width: 2px) {}', + '@media xxxx and (max-width: 2px) {}', + ], + ['each query in a list', '@media ${a}, ${b} {}', '@media xxxx, xxxx {}'], + [ + 'a container name or query right after @container', + '@container ${q} {}', + '@container xxxx {}', + ], + [ + 'a container name before its query', + '@container ${n} (min-width: 1px) {}', + '@container xxxx (min-width: 1px) {}', + ], + [ + 'a placeholder joined to the word before it', + '@media screen and${q} {}', + '@media screen andxxxx {}', + ], + ['a placeholder after a non-condition at-rule', '@page ${q} {}', '@page xxxx {}'], + ['a media type on its own line after @media', '@media\n ${q} {}', '@media\n xxxx {}'], + [ + 'a value on the line after a finished at-rule prelude', + '@media screen and (a) { color:\n ${q}; }', + '@media screen and (a) { color:\n xxxx; }', + ], + ['a longer at-keyword', '@mediax and ${q} {}', '@mediax and xxxx {}'], + ['a media type before a word starting with "or"', '@media ${q} orx {}', '@media xxxx orx {}'], + [ + 'a placeholder inside a comment after "and"', + '@media screen and /* ${q} */ (a) {}', + '@media screen and /* xxxx */ (a) {}', + ], + [ + 'a placeholder after an unterminated comment', + '/* x @media screen and ${q} {}', + '/* x @media screen and xxxx {}', + ], + [ + 'a placeholder after a comment between a word that is not a combinator and it', + '@media screen /* and */ ${q} {}', + '@media screen /* and */ xxxx {}', + ], + /** Only a comment precedes the placeholder, so the at-keyword starts no statement. */ + [ + 'a placeholder after an at-keyword inside a line comment', + '// a; @supports\n${q} {}', + '// a; @supports\nxxxx {}', + ], + /** The line start inside the comment is a statement start, but only a comment precedes the placeholder. */ + [ + 'a placeholder after an at-keyword inside a block comment over lines', + '/* a\n@supports */ ${q} {}', + '/* a\n@supports */ xxxx {}', + ], + ])('should x-fill %s', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it.each([ + [ + 'a border value', + 'border:\n ${a}\n ${b};\ncolor: red;', + 'border:\n \n xxxx;\ncolor: red;', + ], + [ + 'a grid track list', + 'grid-template-columns:\n ${a}\n ${b};', + 'grid-template-columns:\n \n xxxx;', + ], + ])( + 'should keep %s split over lines a value, not a dummy declaration', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + ['two mixins on separate lines at the template start', '${a}\n${b};', ' \n$a:0;'], + [ + 'a mixin on the next line after a mixin after a declaration', + 'color: red; ${a}\n${b};', + 'color: red; \n$a:0;', + ], + ])('should still pass block position on for %s', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it.each([ + ['a class selector', '&.${cls}:hover { color: red; }', '&.xxxxxx:hover { color: red; }'], + ['an id selector', '#${id}:hover { color: red; }', '#xxxxx:hover { color: red; }'], + ['a class name suffix', '&.is-${s}:hover { color: red; }', '&.is-xxxx:hover { color: red; }'], + ['a parent selector suffix', '&${s}:hover { color: red; }', '&xxxx:hover { color: red; }'], + ['a name suffix after it', '${B}-x:hover { color: red; }', 'xxxx-x:hover { color: red; }'], + ])( + 'should x-fill a selector placeholder joined to %s instead of using "&"', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + [ + 'on the next line', + '${B}:not(:last-child)\n {\n color: red;\n }', + '& :not(:last-child)\n {\n color: red;\n }', + ], + ['after a CRLF', '${B}:hover\r\n{ color: red; }', '& :hover\r\n{ color: red; }'], + ['after blank lines', '${B}:hover\n\n { color: red; }', '& :hover\n\n { color: red; }'], + [ + 'on the next line, past a comment', + '${B}:hover\n /* note */ { color: red; }', + '& :hover\n /* note */ { color: red; }', + ], + ])( + 'should read a placeholder followed by a pseudo-class as a selector when its rule body opens %s', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it('should keep a placeholder followed by a pseudo-class a property name when a string, not a comment, sits before the next line', () => { + const value = '${B}:hover\n "note" { color: red; }' + + assert.strictEqual(performSubstitutions(value), '$axx:hover\n "note" { color: red; }') + }) + + it.each([ + ['a keyframe selector at the template start', '${a}% { opacity: 0; }', '0000% { opacity: 0; }'], + [ + 'a keyframe selector after another keyframe block', + '0% { opacity: 0; }\n ${a}% { opacity: 1; }', + '0% { opacity: 0; }\n 0000% { opacity: 1; }', + ], + [ + 'a keyframe selector alone on its line', + '\n ${a}% { opacity: 0; }', + '\n 0000% { opacity: 0; }', + ], + ['a value alone on its line', 'width:\n ${a}%;', 'width:\n 0000%;'], + ])('should fill a placeholder before "%" in %s with zeros', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it.each([ + [ + 'another placeholder with a pseudo-class', + '${C}:hover,\n ${C}:focus {\n color: red;\n }', + '& :hover,\n & :focus {\n color: red;\n }', + ], + [ + 'a parent selector with a pseudo-class', + '${C}:hover,\n &:focus-visible {\n color: red;\n }', + '& :hover,\n &:focus-visible {\n color: red;\n }', + ], + [ + 'several lines ending in ","', + '${C}:hover,\n ${C}:focus,\n ${C}:active {', + '& :hover,\n & :focus,\n & :active {', + ], + [ + 'a line whose rule body opens on the line after it', + '${C}:hover,\n ${C}:focus\n {', + '& :hover,\n & :focus\n {', + ], + ['a CRLF', '${C}:hover,\r\n ${C}:focus {', '& :hover,\r\n & :focus {'], + ])( + 'should read a placeholder with a pseudo-class as a selector when its list continues on the next line with %s', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + ['a selector list of placeholders', '${A},\n${B} {', 'xxxx,\nxxxx {'], + [ + 'a pseudo-class placeholder after a parent selector', + '&:hover,\n ${C}:focus {', + '&:hover,\n & :focus {', + ], + [ + 'a value split over lines after ","', + 'transition:\n ${a},\n ${b};', + 'transition:\n xxxx,\n xxxx;', + ], + ['a font list split over lines', 'font-family: a,\n ${b};', 'font-family: a,\n xxxx;'], + [ + 'a property name whose value continues after ","', + '${p}: a,\n b;\n &:hover {', + '$axx: a,\n b;\n &:hover {', + ], + [ + 'a property name whose value continues after "," before a declaration and a rule on one line', + '${p}: a,\n b; &:hover {', + '$axx: a,\n b; &:hover {', + ], + ])('should keep %s split over lines as it was', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it.each([ + ['a block comment holding ";"', '${A}:hover /* a; b */ {', '& :hover /* a; b */ {'], + ['a block comment holding "}"', '${A}:hover /* } */ {', '& :hover /* } */ {'], + ['a block comment over lines', '${A}:hover /* a;\n b */ {', '& :hover /* a;\n b */ {'], + ['a line comment holding ";"', '${A}:hover // a; b\n{', '& :hover // a; b\n{'], + ['a double-quoted string holding ";"', '${A}:not([x="a;b"]) {', '& :not([x="a;b"]) {'], + ["a single-quoted string holding '}'", "${A}:not([x='a}b']) {", "& :not([x='a}b']) {"], + [ + 'a string holding "//" and ";"', + '${A}:not([x^="http://a;b"]) {', + '& :not([x^="http://a;b"]) {', + ], + ['an unquoted url() holding ";" and "}"', '${A}:is(url(a;}b)) {', '& :is(url(a;}b)) {'], + [ + 'a comment holding ";" in a selector list on one line', + '${A}:hover, /* a; b */ ${B}:focus {', + '& :hover, /* a; b */ & :focus {', + ], + [ + 'a comment after the "," that ends a selector list line', + '${A}:hover, /* a; b */\n ${B}:focus {', + '& :hover, /* a; b */\n & :focus {', + ], + [ + 'a line comment after the "," that ends a selector list line', + '${A}:hover, // a; b\n ${B}:focus {', + '& :hover, // a; b\n & :focus {', + ], + [ + 'a line comment after the "," that ends a selector list line, with a CRLF', + '${A}:hover, // a; b\r\n ${B}:focus {', + '& :hover, // a; b\r\n & :focus {', + ], + [ + 'a comment holding ";" on a selector list line', + '${A}:hover,\n /* a; b */ ${B}:focus {', + '& :hover,\n /* a; b */ & :focus {', + ], + [ + 'a comment holding ";" after a placeholder joined to a class name', + '&.${v}:hover /* a; b */ {', + '&.xxxx:hover /* a; b */ {', + ], + ])( + 'should read a placeholder with a pseudo-class as a selector past %s', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + ['a comment', '${A}: 0 /* c */; &:hover {', '$axx: 0 /* c */; &:hover {'], + ['a string', '${A}: "a" ; &:hover {', '$axx: "a" ; &:hover {'], + ['a comment, before "}"', '${A}: 0 /* { */ } &:hover {', '$axx: 0 /* { */ } &:hover {'], + [ + 'a line comment, before a line break', + '${A}: red // c\n&:hover {', + '$axx: red // c\n&:hover {', + ], + [ + 'a line comment after ",", on a value continued over lines', + '${A}: a, // c\n b;\n&:hover {', + '$axx: a, // c\n b;\n&:hover {', + ], + ])( + 'should keep a placeholder a property name when a real ";" or "}" follows %s', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + /** + * A comment after the "," leaves "," the line's last code character, so the list continues and + * `b {` makes a selector; anything else there ends the list at the line break, and a next line + * that does not start with "{" makes a property name. + */ + it.each([ + ['a double-quoted string', '${A}:hover, "a"\n b {', '$axx:hover, "a"\n b {'], + [ + 'a single-quoted string, before a CRLF', + "${A}:hover, 'a'\r\n b {", + "$axx:hover, 'a'\r\n b {", + ], + ['an unquoted url()', '${A}:hover, url(a)\n b {', '$axx:hover, url(a)\n b {'], + ['a CSS-escaped ","', '${A}:hover, \\\\,\n b {', '$axx:hover, \\\\,\n b {'], + ['a block comment', '${A}:hover, /* a */\n b {', '& :hover, /* a */\n b {'], + ])( + 'should read a line ending in %s after its "," as a selector list line only for a comment', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + ['an unterminated block comment', 'a: 0 /* \n ${m}', 'a: 0 /* \n '], + ['an unterminated block comment, after a ";" in it', 'a: 0 /* ;\n ${m}', 'a: 0 /* ;\n '], + ])('should start a new line at a line break inside %s', (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }) + + it.each([ + ['a double-quoted string holding "{"', '${A}: "a{b";', '$axx: "a{b";'], + ["a single-quoted string holding '&'", "${A}: 'a&b';", "$axx: 'a&b';"], + ['a block comment holding "{"', '${A}: 0 /* { */;', '$axx: 0 /* { */;'], + ['a block comment holding "&"', '${A}: 0 /* & */;', '$axx: 0 /* & */;'], + ['a line comment holding "{"', '${A}: 0 // {\n;', '$axx: 0 // {\n;'], + ['an unquoted url() holding "{" and "&"', '${A}: url(a{&b);', '$axx: url(a{&b);'], + ])( + 'should keep a placeholder a property name when its only "{" or "&" is inside %s', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + [ + 'a comment holding ";" on its line', + '@media screen and /* a; b */ ${q} {', + '@media screen and /* a; b */ (x ) {', + ], + [ + 'a comment holding ";" on the line before', + '@media screen and\n/* a; b */ ${q} {', + '@media screen and\n/* a; b */ (x ) {', + ], + [ + 'a string holding ";"', + '@supports (content: "a;b") and ${q} {', + '@supports (content: "a;b") and x( ) {', + ], + [ + 'an unquoted url() holding "}"', + '@supports (background: url(a}b)) and ${q} {', + '@supports (background: url(a}b)) and x( ) {', + ], + ])( + 'should read an at-rule condition placeholder after %s as a condition', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it('should keep a placeholder followed by a colon a property name when the next line starts with other text', () => { + assert.strictEqual(performSubstitutions('${p}: red\ncolor: blue;'), '$axx: red\ncolor: blue;') + }) + + it('should skip a comment between a placeholder and the ":" that follows it, keeping it a property name the same as without the comment', () => { + const control = '${p}: red;' + const withComment = '${p} /* note */: red;' + + assert.strictEqual(performSubstitutions(control), '$axx: red;') + assert.strictEqual(performSubstitutions(withComment), '$axx /* note */: red;') + }) + + it('should treat a string between a placeholder and the ":" that follows it as significant, not skip it like a comment', () => { + const value = '${p} "note": red;' + + assert.strictEqual(performSubstitutions(value), ' "note": red;') + }) + + it('should skip a comment between a placeholder joined to a name and the ":" that follows it, keeping the joined-name fill the same as without the comment', () => { + const control = '${side}-top: 0;' + const withComment = '${side}-top /* note */: 0;' + + assert.strictEqual(performSubstitutions(control), '#{x }-top: 0;') + assert.strictEqual(performSubstitutions(withComment), '#{x }-top /* note */: 0;') + }) + + it('should treat a string after a placeholder joined to a name as significant, not skip it like a comment', () => { + const value = '${side}-top "note": 0;' + + assert.strictEqual(performSubstitutions(value), ' -top "note": 0;') + }) + + /** + * A comment anywhere makes every scan read the text with the boundary scanner; one after the + * last placeholder changes no decision before it, so each shape reads as it does without one. + */ + it.each([ + ['a selector list over lines', '${C}:hover,\n ${C}:focus {', '& :hover,\n & :focus {'], + [ + 'a selector list over a CRLF', + '${C}:hover,\r\n ${C}:focus {', + '& :hover,\r\n & :focus {', + ], + ['a value continued after ","', '${p}: a,\n b;\n &:hover {', '$axx: a,\n b;\n &:hover {'], + [ + 'a joined property name before a rule', + 'padding-${s}: 0; &:hover {', + 'padding-#{x}: 0; &:hover {', + ], + [ + 'a mixin after a closing brace', + 'a { color: red; } ${m}\nb: 0;', + 'a { color: red; } \nb: 0;', + ], + ['a mixin inside a one-line rule body', '&:hover { ${m} }', '&:hover { }'], + ['two mixins after a declaration line', 'color: red;\n${a} ${b}\n', 'color: red;\n \n'], + [ + 'a condition after a statement', + 'a: 0; @media screen and ${q} {', + 'a: 0; @media screen and (x ) {', + ], + [ + 'a condition after a closing brace', + 'a { } @media screen and ${q} {', + 'a { } @media screen and (x ) {', + ], + ['a custom property name after a closing brace', 'a { } --x-${p}: 0;', 'a { } --x-xxxx: 0;'], + ])( + 'should substitute %s the same when a comment follows the last placeholder', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + assert.strictEqual(performSubstitutions(`${value} /* c */`), `${expected} /* c */`) + }, + ) + + it.each([ + ['";"', 'background: url(a;${x}) no-repeat;', 'background: url(a;xxxx) no-repeat;'], + ['"{"', 'background: url(a{${x}) no-repeat;', 'background: url(a{xxxx) no-repeat;'], + ])( + 'should read a placeholder inside a url() after %s as part of the url, not a mixin', + (_description, template, expected) => { + assert.strictEqual(performSubstitutions(template), expected) + }, + ) + + it('should replace placeholder used in child selector (#75)', () => { + assert.deepEqual( + performSubstitutions( + ['position: relative;', '> ${FlipContainer}:hover {', ' color: red;', '}'].join('\n'), + ), + ['position: relative;', '> & :hover {', ' color: red;', '}'].join('\n'), + ) + }) +}) + +function performSubstitutions(value: string) { + return getTemplateSubstitutions(value, getSpans(value)) +} + +function getSpans(value: string) { + return Array.from(value.matchAll(/\$\{[^}]*\}/g), (match) => ({ + end: match.index + match[0].length, + start: match.index, + })) +} diff --git a/test/unit/tsserver-plugin.test.ts b/test/unit/tsserver-plugin.test.ts new file mode 100644 index 0000000..c149f50 --- /dev/null +++ b/test/unit/tsserver-plugin.test.ts @@ -0,0 +1,107 @@ +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { assert, describe, it } from 'vitest' + +import { pluginIdentity } from '../../src/tsserver/plugin-identity' +import { + isSupportedTypeScriptVersion, + TsServerStyledPlugin, +} from '../../src/tsserver/tsserver-plugin' + +describe('TsServerStyledPlugin', () => { + it('should log the resolved configuration after a configuration change', () => { + const messages: string[] = [] + const plugin = new TsServerStyledPlugin(ts) + plugin.create(createPluginCreateInfo(messages)) + messages.length = 0 + + plugin.onConfigurationChanged({ tags: ['sty'], validate: 'false' }) + + const configPrefix = `[${pluginIdentity}] config: ` + assert.deepEqual( + messages.map((message) => (message.startsWith(configPrefix) ? configPrefix : message)), + [`[${pluginIdentity}] onConfigurationChanged`, configPrefix], + ) + assert.deepEqual(JSON.parse(messages[1]?.slice(configPrefix.length) ?? ''), { + emmet: {}, + lint: { emptyRules: 'ignore' }, + tags: ['sty'], + validate: false, + }) + }) + + it.each([ + ['a bigint', { emmet: { unknownOption: 1n }, tags: ['sty'] }], + ['a cycle', { emmet: createCycle(), tags: ['sty'] }], + ])( + 'should apply and log a configuration holding %s JSON cannot represent', + (_name, configuration) => { + const messages: string[] = [] + const plugin = new TsServerStyledPlugin(ts) + plugin.create(createPluginCreateInfo(messages)) + messages.length = 0 + + plugin.onConfigurationChanged(configuration) + + const configPrefix = `[${pluginIdentity}] config: ` + assert.deepEqual( + messages.map((message) => (message.startsWith(configPrefix) ? configPrefix : message)), + [`[${pluginIdentity}] onConfigurationChanged`, configPrefix], + ) + }, + ) +}) + +function createCycle(): Record { + const cycle: Record = {} + cycle.self = cycle + return cycle +} + +/** + * The fields of PluginCreateInfo the plugin reads: its configuration, the host language service + * it decorates, and the project service logger its log lines go to. A real tsserver Project needs a + * running server host, so the rest of the object is left out and the shape is asserted once here. + */ +function createPluginCreateInfo(messages: string[]): ts.server.PluginCreateInfo { + const host: ts.LanguageServiceHost = { + fileExists: () => false, + getCompilationSettings: () => ({}), + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => 'lib.d.ts', + getScriptFileNames: () => [], + getScriptSnapshot: () => undefined, + getScriptVersion: () => '0', + readFile: () => undefined, + } + const pluginCreateInfo = { + config: {}, + languageService: ts.createLanguageService(host), + project: { + projectService: { + logger: { + info(message: string) { + messages.push(message) + }, + }, + }, + }, + } + return pluginCreateInfo as unknown as ts.server.PluginCreateInfo +} + +describe('isSupportedTypeScriptVersion', () => { + it.each([ + ['TypeScript 2.9, below the 5.0 floor', '2.9.0', false], + ['TypeScript 3.9, below the 5.0 floor', '3.9.10', false], + ['TypeScript 4.9, below the 5.0 floor', '4.9.5', false], + ['TypeScript 5.0, the floor', '5.0.4', true], + ['TypeScript 5.9', '5.9.3', true], + ['TypeScript 6.0', '6.0.0', true], + ['current TypeScript 6 baseline', '6.0.3', true], + ['future TypeScript major with no declared upper bound', '7.0.0', true], + ['prerelease version string on the floor major', '5.0.0-beta', true], + ['prerelease version string on a future major', '7.1.0-dev.20260925.1', true], + ])('should return %s support status', (_name, version, supported) => { + assert.strictEqual(isSupportedTypeScriptVersion({ version }), supported) + }) +}) diff --git a/test/unit/validation-cache.test.ts b/test/unit/validation-cache.test.ts new file mode 100644 index 0000000..6edb383 --- /dev/null +++ b/test/unit/validation-cache.test.ts @@ -0,0 +1,149 @@ +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { assert, describe, it } from 'vitest' +import type { Diagnostic } from 'vscode-css-languageservice' + +import { + buildValidationCacheKey, + MAX_VALIDATION_CACHE_BYTES, + MAX_VALIDATION_CACHE_ENTRIES, + RawValidationCache, + VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES, +} from '../../src/features/diagnostics' +import { StyledVirtualDocumentProvider } from '../../src/virtual-document/styled-virtual-document-provider' +import { createTemplateContext } from './create-template-context' + +/** + * Mirrors the estimate docs/architecture.md and src/features/diagnostics.ts document for an + * entry's size: key length times 2 bytes, plus, per diagnostic, message length times 2 bytes + * plus the fixed overhead constant. Solving that formula for a message length that lands an + * entry at a target byte count (using only the exported constants), rather than calling the + * cache's own private size estimator, keeps these tests a check against the documented contract + * instead of the implementation checking itself. + */ +function messageLengthForBytes(keyLength: number, targetBytes: number): number { + return Math.ceil((targetBytes - keyLength * 2 - VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES) / 2) +} + +function makeDiagnostic(messageLength: number): Diagnostic { + return { + message: 'x'.repeat(messageLength), + range: { end: { character: 1, line: 0 }, start: { character: 0, line: 0 } }, + } +} + +/** Fixed-length raw text so every key built from it is the same length regardless of content. */ +function rawTextOf(fill: string): string { + return fill.repeat(20) +} + +const readingKey = new StyledVirtualDocumentProvider(ts).getReadingKey( + createTemplateContext('color: red;', 'styled.div'), +) + +describe('buildValidationCacheKey', () => { + it('builds distinct keys when the tag kind or raw text differs', () => { + const provider = new StyledVirtualDocumentProvider(ts) + const text = 'colr' + const keyFor = (tagName: string, rawText = text) => + buildValidationCacheKey(provider.getReadingKey(createTemplateContext(text, tagName)), rawText) + + const keys = [ + keyFor('styled.div'), + /** The keyframes wrapper, same nesting mode and flag. */ + keyFor('keyframes'), + /** The global-style top level, same wrapper and flag. */ + keyFor('createGlobalStyle'), + /** A single-identifier css fragment, same wrapper and nesting mode. */ + keyFor('css'), + keyFor('styled.div', 'color'), + ] + + assert.strictEqual(new Set(keys).size, keys.length) + assert.strictEqual(keyFor('styled.div'), keys[0], 'the same tag kind and text build one key') + assert.strictEqual(keyFor('styled.button'), keys[0], 'two component tags share a tag kind') + assert.strictEqual(keyFor('injectGlobal'), keys[2], 'two global-style tags share a tag kind') + }) +}) + +describe('RawValidationCache', () => { + it('evicts the least recently used entry once the byte budget is exceeded, well under the count cap', () => { + const cache = new RawValidationCache() + const keyA = buildValidationCacheKey(readingKey, rawTextOf('a')) + const keyB = buildValidationCacheKey(readingKey, rawTextOf('b')) + /** Each entry alone is 60% of the byte budget, so two together (120%) force an eviction. */ + const messageLength = messageLengthForBytes(keyA.length, MAX_VALIDATION_CACHE_BYTES * 0.6) + const diagnosticsA = [makeDiagnostic(messageLength)] + const diagnosticsB = [makeDiagnostic(messageLength)] + + cache.set(keyA, diagnosticsA) + assert.deepEqual(cache.peek(keyA), diagnosticsA) + + cache.set(keyB, diagnosticsB) + + assert.isUndefined(cache.peek(keyA), 'the oldest entry evicts once the budget is exceeded') + assert.deepEqual(cache.peek(keyB), diagnosticsB) + }) + + it('evicts the oldest entry once the count cap is reached, even though every entry is tiny', () => { + const cache = new RawValidationCache() + const keys = Array.from({ length: MAX_VALIDATION_CACHE_ENTRIES + 1 }, (_, index) => + buildValidationCacheKey(readingKey, `rule-${index}`), + ) + + for (const key of keys) { + cache.set(key, [makeDiagnostic(1)]) + } + + const firstKey = keys[0] + const lastKey = keys[keys.length - 1] + assert.isDefined(firstKey) + assert.isDefined(lastKey) + + assert.isUndefined(cache.peek(firstKey), 'the entry inserted before the cap fills evicts') + assert.isDefined(cache.peek(lastKey), 'the most recently inserted entry stays cached') + }) + + it('never caches a single entry larger than the byte budget alone, and does not evict to make room for it', () => { + const cache = new RawValidationCache() + const smallKey = buildValidationCacheKey(readingKey, rawTextOf('s')) + const smallDiagnostics = [makeDiagnostic(1)] + cache.set(smallKey, smallDiagnostics) + + const oversizedKey = buildValidationCacheKey(readingKey, rawTextOf('o')) + const oversizedMessageLength = messageLengthForBytes( + oversizedKey.length, + MAX_VALIDATION_CACHE_BYTES * 1.5, + ) + cache.set(oversizedKey, [makeDiagnostic(oversizedMessageLength)]) + + assert.isUndefined(cache.peek(oversizedKey), 'an entry bigger than the budget is never cached') + assert.deepEqual( + cache.peek(smallKey), + smallDiagnostics, + 'an oversized rejected entry must not evict what is already cached', + ) + }) + + it('treats a peek as a recency touch, so the least recently peeked entry evicts first under the byte budget', () => { + const cache = new RawValidationCache() + const keyA = buildValidationCacheKey(readingKey, rawTextOf('a')) + const keyB = buildValidationCacheKey(readingKey, rawTextOf('b')) + const keyC = buildValidationCacheKey(readingKey, rawTextOf('c')) + /** Two of these fit under the budget; three do not, forcing exactly one eviction. */ + const messageLength = messageLengthForBytes(keyA.length, MAX_VALIDATION_CACHE_BYTES * 0.4) + const diagnosticsA = [makeDiagnostic(messageLength)] + const diagnosticsB = [makeDiagnostic(messageLength)] + const diagnosticsC = [makeDiagnostic(messageLength)] + + cache.set(keyA, diagnosticsA) + cache.set(keyB, diagnosticsB) + /** Touching A moves it to the back, leaving B as the least recently used entry. */ + cache.peek(keyA) + + cache.set(keyC, diagnosticsC) + + assert.isUndefined(cache.peek(keyB), 'B, not A, was least recently used and evicts') + assert.deepEqual(cache.peek(keyA), diagnosticsA) + assert.deepEqual(cache.peek(keyC), diagnosticsC) + }) +}) diff --git a/test/unit/virtual-document-session-provider.test.ts b/test/unit/virtual-document-session-provider.test.ts new file mode 100644 index 0000000..84ede00 --- /dev/null +++ b/test/unit/virtual-document-session-provider.test.ts @@ -0,0 +1,136 @@ +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { assert, describe, it } from 'vitest' +import type { TextDocument } from 'vscode-languageserver-textdocument' + +import { + DefaultStylesLanguageServiceFactory, + type ScssLanguageService, +} from '../../src/features/styles-language-services' +import { StyledVirtualDocumentProvider } from '../../src/virtual-document/styled-virtual-document-provider' +import { createTemplateLineMap } from '../../src/virtual-document/template-line-map' +import { CachedVirtualDocumentSessionProvider } from '../../src/virtual-document/virtual-document-session-provider' +import { createTemplateContext } from './create-template-context' + +describe('CachedVirtualDocumentSessionProvider', () => { + it('should reuse the same line map only when the virtual document is reusable', () => { + /** A reusable document never recomputes its line-start table (docs/architecture.md, "Virtual document"). */ + const sessionProvider = createSessionProvider() + const first = createTemplateContext('color: red;\nmargin: 0;') + const second = { ...first } + const third = createTemplateContext('padding: 0;') + + const firstLineMap = sessionProvider.getDocument(first).lineMap + const secondLineMap = sessionProvider.getDocument(second).lineMap + const thirdLineMap = sessionProvider.getDocument(third).lineMap + + assert.strictEqual(secondLineMap, firstLineMap) + assert.notStrictEqual(thirdLineMap, firstLineMap) + }) + + it('should compute a line map whose template end matches the raw text length', () => { + const sessionProvider = createSessionProvider() + const context = createTemplateContext('a {\n color: red;\n}\ncolr: blue;') + + const { lineMap } = sessionProvider.getDocument(context) + + assert.deepEqual(lineMap.lineStarts, [0, 4, 18, 20]) + assert.deepEqual(lineMap.templateEnd, { + offset: context.rawText.length, + position: { line: 3, character: 'colr: blue;'.length }, + }) + }) + + it('should recompute the line map after a raw-text change invalidates the cached document', () => { + const sessionProvider = createSessionProvider() + const first = createTemplateContext('color: red;') + const second = createTemplateContext('color: red;\nmargin: 0;') + + sessionProvider.getDocument(first) + const secondLineMap = sessionProvider.getDocument(second).lineMap + + assert.strictEqual(secondLineMap.templateEnd.offset, second.rawText.length) + }) + + it('should share one line map per template context with the document built from it', () => { + const sessionProvider = createSessionProvider() + const context = createTemplateContext('color: red;\nmargin: 0;') + + const { document, lineMap } = sessionProvider.getParsedDocument(context) + + assert.strictEqual(lineMap, createTemplateLineMap(context)) + assert.strictEqual(sessionProvider.getDocument(context).lineMap, lineMap) + assert.strictEqual(sessionProvider.getDocument(context).document, document) + }) + + it('should expose the cached line map only for a reusable document, without building one', () => { + const sessionProvider = createSessionProvider() + const first = createTemplateContext('color: red;\nmargin: 0;') + const other = createTemplateContext('padding: 0;') + + assert.isUndefined(sessionProvider.getReusableLineMap(first)) + + const firstLineMap = sessionProvider.getDocument(first).lineMap + + assert.strictEqual(sessionProvider.getReusableLineMap({ ...first }), firstLineMap) + assert.isUndefined(sessionProvider.getReusableLineMap(other)) + assert.strictEqual(sessionProvider.getDocument(first).lineMap, firstLineMap) + }) + + it('should parse a new stylesheet when the document changes, and reuse it while the document is reused', () => { + const scssLanguageService = + new DefaultStylesLanguageServiceFactory().createScssLanguageService() + const parsedTexts: string[] = [] + const recordingService: ScssLanguageService = { + ...scssLanguageService, + parseStylesheet(document: TextDocument) { + parsedTexts.push(document.getText()) + return scssLanguageService.parseStylesheet(document) + }, + } + const sessionProvider = new CachedVirtualDocumentSessionProvider( + new StyledVirtualDocumentProvider(ts), + recordingService, + ) + const first = createTemplateContext('color: red;') + const other = createTemplateContext('margin: 0;') + + const firstParsed = sessionProvider.getParsedDocument(first) + const reused = sessionProvider.getParsedDocument({ ...first }) + const otherParsed = sessionProvider.getParsedDocument(other) + + assert.strictEqual(reused.stylesheet, firstParsed.stylesheet) + assert.notStrictEqual(otherParsed.stylesheet, firstParsed.stylesheet) + assert.deepEqual(parsedTexts, [':root{\ncolor: red;\n}', ':root{\nmargin: 0;\n}']) + }) + + it.each([ + ['styled.div', 'createGlobalStyle'], + ['createGlobalStyle', 'styled.div'], + ])( + 'should build a new document for the same text under a tag that reads it differently, %s then %s', + (firstTag, secondTag) => { + const sessionProvider = createSessionProvider() + const text = '@layer x { color: red; }' + const nested = `:root{\n${' '.repeat('@layer x '.length - 1)}&{ color: red; }\n}` + const topLevel = `:root{\n${text}\n}` + const expected = (tagName: string) => (tagName === 'styled.div' ? nested : topLevel) + + const firstDocument = sessionProvider.getDocument( + createTemplateContext(text, firstTag), + ).document + const secondDocument = sessionProvider.getDocument( + createTemplateContext(text, secondTag), + ).document + + assert.strictEqual(firstDocument.getText(), expected(firstTag)) + assert.strictEqual(secondDocument.getText(), expected(secondTag)) + }, + ) +}) + +function createSessionProvider(): CachedVirtualDocumentSessionProvider { + return new CachedVirtualDocumentSessionProvider( + new StyledVirtualDocumentProvider(ts), + new DefaultStylesLanguageServiceFactory().createScssLanguageService(), + ) +} diff --git a/test/unit/with-deadline.test.ts b/test/unit/with-deadline.test.ts new file mode 100644 index 0000000..740cc25 --- /dev/null +++ b/test/unit/with-deadline.test.ts @@ -0,0 +1,61 @@ +import { afterEach, assert, describe, it, vi } from 'vitest' + +import { killGroup } from '../../scripts/kill-group.ts' + +function errnoError(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException + error.code = code + return error +} + +describe('killGroup', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('signals the negated pid (the process group) and logs nothing on success', () => { + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + assert.doesNotThrow(() => killGroup(1234, 'SIGTERM')) + + assert.deepEqual(kill.mock.calls, [[-1234, 'SIGTERM']]) + assert.strictEqual(errorSpy.mock.calls.length, 0) + }) + + it('does nothing when there is no pid to signal (the child never started)', () => { + const kill = vi.spyOn(process, 'kill') + killGroup(undefined, 'SIGTERM') + assert.strictEqual(kill.mock.calls.length, 0) + }) + + it('is a silent no-op for ESRCH, the benign race where the group already exited', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw errnoError('ESRCH') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + assert.doesNotThrow(() => killGroup(1234, 'SIGTERM')) + + assert.strictEqual(errorSpy.mock.calls.length, 0) + }) + + /** + * The regression this guards: killGroup runs inside the SIGINT/SIGTERM handler and the deadline + * timer, both synchronous callbacks with no caller to catch a throw, so any error besides ESRCH + * (EPERM, for example) used to escape uncaught and crash the wrapper mid-shutdown instead of + * finishing it. + */ + it('logs and returns, without throwing, when process.kill fails with an error other than ESRCH', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw errnoError('EPERM') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + assert.doesNotThrow(() => killGroup(1234, 'SIGTERM')) + + assert.strictEqual(errorSpy.mock.calls.length, 1) + assert.match(String(errorSpy.mock.calls[0]?.[0]), /EPERM/) + assert.match(String(errorSpy.mock.calls[0]?.[0]), /SIGTERM/) + }) +}) From 12e697d347ceb4d2536a8d8544815f9612bfe1e8 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sun, 27 Sep 2026 14:55:10 -0400 Subject: [PATCH 3/5] test: drive a real tsserver end to end and gate the packed package and scaling behavior A shared tsserver per suite file runs every scenario across the supported TypeScript range; package tests consume the packed tarball from CommonJS and ESM and type-check consumers on TypeScript 5.0 through the current release; the scaling check guards superlinear time and retained memory. Co-authored-by: usercao --- e2e/disabled-emmet-project-fixture/index.ts | 3 - e2e/disabled-emmet-project-fixture/main.ts | 0 .../tsconfig.json | 9 - e2e/package-lock.json | 3971 ----------------- e2e/package.json | 20 - e2e/project-fixture/.vscode/settings.json | 3 - e2e/project-fixture/index.ts | 3 - e2e/project-fixture/main.ts | 0 e2e/project-fixture/tsconfig.json | 7 - e2e/server-fixture/index.js | 86 - e2e/tests/_helpers.js | 24 - e2e/tests/completionEntryDetails.js | 24 - e2e/tests/completions.js | 249 -- e2e/tests/emmetCompletions.js | 155 - e2e/tests/errors.js | 402 -- e2e/tests/outliningSpans.js | 45 - e2e/tests/quickFix.js | 85 - test/e2e/closed-file-project-fixture/entry.ts | 1 + .../closed-file-project-fixture/tsconfig.json | 16 + .../closed-file-project-fixture/unclosed.ts | 8 + test/e2e/default-tags-project-fixture/main.ts | 1 + .../tsconfig.json | 15 + .../emmet-disabled-project-fixture/main.ts | 1 + .../tsconfig.json | 13 + test/e2e/fixture-paths.ts | 12 + test/e2e/package.json | 15 + .../plugin-missing-project-fixture/main.ts | 1 + .../tsconfig.json | 6 + test/e2e/scenarios/code-fixes.test.ts | 159 + .../completion-entry-details.test.ts | 25 + test/e2e/scenarios/completions.test.ts | 295 ++ .../scenarios/edit-outside-template.test.ts | 117 + test/e2e/scenarios/emmet-completions.test.ts | 158 + test/e2e/scenarios/errors.test.ts | 841 ++++ test/e2e/scenarios/hover.test.ts | 21 + test/e2e/scenarios/markers.test.ts | 43 + test/e2e/scenarios/outlining-spans.test.ts | 25 + test/e2e/scenarios/plugin-lifecycle.test.ts | 230 + test/e2e/scenarios/script-kinds.test.ts | 29 + .../styled-components-syntax.test.ts | 58 + test/e2e/styled-project-fixture/main.ts | 1 + .../styled-project-fixture/script-kind-js.js | 1 + .../script-kind-jsx.jsx | 1 + .../script-kind-tsx.tsx | 1 + test/e2e/styled-project-fixture/tsconfig.json | 24 + test/e2e/tsconfig.json | 9 + .../e2e/tsserver-fixture/check-plugin-link.ts | 25 + test/e2e/tsserver-fixture/clear-logs.ts | 51 + test/e2e/tsserver-fixture/helpers.ts | 101 + test/e2e/tsserver-fixture/markers.ts | 74 + test/e2e/tsserver-fixture/message-reader.ts | 34 + test/e2e/tsserver-fixture/server.ts | 361 ++ test/package-api/consumer-1.0.1-shape.cts | 52 + test/package-api/consumer-api.cts | 21 + test/package-api/consumer-lib-api-default.mts | 12 + test/package-api/consumer-node10.cts | 22 + test/package-api/consumer-root.ts | 6 + test/package-api/consumer.cts | 6 + test/package-api/consumer.ts | 88 + test/package-api/packed-package.cjs | 46 + test/package-api/packed-package.d.cts | 15 + test/package-api/runtime-esm-consumer.mjs | 142 + test/package-api/runtime.ts | 124 + test/package-api/tsconfig.consumers.5.5.json | 8 + test/package-api/tsconfig.consumers.5.9.json | 8 + .../tsconfig.consumers.bundler.json | 14 + .../tsconfig.consumers.current.json | 8 + test/package-api/tsconfig.consumers.json | 18 + .../tsconfig.consumers.minimum.json | 8 + .../tsconfig.consumers.node10.json | 15 + test/package-api/tsconfig.json | 11 + test/package-api/tsserver-smoke.cjs | 343 ++ test/performance/scaling-check-cli.ts | 97 + test/performance/scaling-check.ts | 1342 ++++++ .../template-language-service-fixture.ts | 171 + .../template-language-service.bench.ts | 422 ++ .../validation-cache-key-flattening-probe.mjs | 130 + test/unit/scaling-check-cli.test.ts | 95 + test/unit/tsserver-message-reader.test.ts | 57 + 79 files changed, 6054 insertions(+), 5086 deletions(-) delete mode 100644 e2e/disabled-emmet-project-fixture/index.ts delete mode 100644 e2e/disabled-emmet-project-fixture/main.ts delete mode 100644 e2e/disabled-emmet-project-fixture/tsconfig.json delete mode 100644 e2e/package-lock.json delete mode 100644 e2e/package.json delete mode 100644 e2e/project-fixture/.vscode/settings.json delete mode 100644 e2e/project-fixture/index.ts delete mode 100644 e2e/project-fixture/main.ts delete mode 100644 e2e/project-fixture/tsconfig.json delete mode 100644 e2e/server-fixture/index.js delete mode 100644 e2e/tests/_helpers.js delete mode 100644 e2e/tests/completionEntryDetails.js delete mode 100644 e2e/tests/completions.js delete mode 100644 e2e/tests/emmetCompletions.js delete mode 100644 e2e/tests/errors.js delete mode 100644 e2e/tests/outliningSpans.js delete mode 100644 e2e/tests/quickFix.js create mode 100644 test/e2e/closed-file-project-fixture/entry.ts create mode 100644 test/e2e/closed-file-project-fixture/tsconfig.json create mode 100644 test/e2e/closed-file-project-fixture/unclosed.ts create mode 100644 test/e2e/default-tags-project-fixture/main.ts create mode 100644 test/e2e/default-tags-project-fixture/tsconfig.json create mode 100644 test/e2e/emmet-disabled-project-fixture/main.ts create mode 100644 test/e2e/emmet-disabled-project-fixture/tsconfig.json create mode 100644 test/e2e/fixture-paths.ts create mode 100644 test/e2e/package.json create mode 100644 test/e2e/plugin-missing-project-fixture/main.ts create mode 100644 test/e2e/plugin-missing-project-fixture/tsconfig.json create mode 100644 test/e2e/scenarios/code-fixes.test.ts create mode 100644 test/e2e/scenarios/completion-entry-details.test.ts create mode 100644 test/e2e/scenarios/completions.test.ts create mode 100644 test/e2e/scenarios/edit-outside-template.test.ts create mode 100644 test/e2e/scenarios/emmet-completions.test.ts create mode 100644 test/e2e/scenarios/errors.test.ts create mode 100644 test/e2e/scenarios/hover.test.ts create mode 100644 test/e2e/scenarios/markers.test.ts create mode 100644 test/e2e/scenarios/outlining-spans.test.ts create mode 100644 test/e2e/scenarios/plugin-lifecycle.test.ts create mode 100644 test/e2e/scenarios/script-kinds.test.ts create mode 100644 test/e2e/scenarios/styled-components-syntax.test.ts create mode 100644 test/e2e/styled-project-fixture/main.ts create mode 100644 test/e2e/styled-project-fixture/script-kind-js.js create mode 100644 test/e2e/styled-project-fixture/script-kind-jsx.jsx create mode 100644 test/e2e/styled-project-fixture/script-kind-tsx.tsx create mode 100644 test/e2e/styled-project-fixture/tsconfig.json create mode 100644 test/e2e/tsconfig.json create mode 100644 test/e2e/tsserver-fixture/check-plugin-link.ts create mode 100644 test/e2e/tsserver-fixture/clear-logs.ts create mode 100644 test/e2e/tsserver-fixture/helpers.ts create mode 100644 test/e2e/tsserver-fixture/markers.ts create mode 100644 test/e2e/tsserver-fixture/message-reader.ts create mode 100644 test/e2e/tsserver-fixture/server.ts create mode 100644 test/package-api/consumer-1.0.1-shape.cts create mode 100644 test/package-api/consumer-api.cts create mode 100644 test/package-api/consumer-lib-api-default.mts create mode 100644 test/package-api/consumer-node10.cts create mode 100644 test/package-api/consumer-root.ts create mode 100644 test/package-api/consumer.cts create mode 100644 test/package-api/consumer.ts create mode 100644 test/package-api/packed-package.cjs create mode 100644 test/package-api/packed-package.d.cts create mode 100644 test/package-api/runtime-esm-consumer.mjs create mode 100644 test/package-api/runtime.ts create mode 100644 test/package-api/tsconfig.consumers.5.5.json create mode 100644 test/package-api/tsconfig.consumers.5.9.json create mode 100644 test/package-api/tsconfig.consumers.bundler.json create mode 100644 test/package-api/tsconfig.consumers.current.json create mode 100644 test/package-api/tsconfig.consumers.json create mode 100644 test/package-api/tsconfig.consumers.minimum.json create mode 100644 test/package-api/tsconfig.consumers.node10.json create mode 100644 test/package-api/tsconfig.json create mode 100644 test/package-api/tsserver-smoke.cjs create mode 100644 test/performance/scaling-check-cli.ts create mode 100644 test/performance/scaling-check.ts create mode 100644 test/performance/template-language-service-fixture.ts create mode 100644 test/performance/template-language-service.bench.ts create mode 100644 test/performance/validation-cache-key-flattening-probe.mjs create mode 100644 test/unit/scaling-check-cli.test.ts create mode 100644 test/unit/tsserver-message-reader.test.ts diff --git a/e2e/disabled-emmet-project-fixture/index.ts b/e2e/disabled-emmet-project-fixture/index.ts deleted file mode 100644 index 525db1e..0000000 --- a/e2e/disabled-emmet-project-fixture/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -css` - -` \ No newline at end of file diff --git a/e2e/disabled-emmet-project-fixture/main.ts b/e2e/disabled-emmet-project-fixture/main.ts deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/disabled-emmet-project-fixture/tsconfig.json b/e2e/disabled-emmet-project-fixture/tsconfig.json deleted file mode 100644 index e5c52d5..0000000 --- a/e2e/disabled-emmet-project-fixture/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "target": "es2015", - "module": "commonjs", - "plugins": [ - { "name": "@styled/typescript-styled-plugin", "tags": ["css"], "emmet": { "showExpandedAbbreviation": "never"} } - ] - } -} diff --git a/e2e/package-lock.json b/e2e/package-lock.json deleted file mode 100644 index 7a353cd..0000000 --- a/e2e/package-lock.json +++ /dev/null @@ -1,3971 +0,0 @@ -{ - "name": "e2e", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "e2e", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@styled/typescript-styled-plugin": "file:./..", - "typescript": "^5.3.3" - } - }, - "..": { - "name": "@styled/typescript-styled-plugin", - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "@vscode/emmet-helper": "^2.9.2", - "typescript-template-language-service-decorator": "^2.3.2", - "vscode-css-languageservice": "^6.2.11", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "^3.17.5" - }, - "devDependencies": { - "@types/chai": "^4.3.4", - "@types/mocha": "^10.0.1", - "@types/node": "^18.11.9", - "@typescript-eslint/eslint-plugin": "^5.6.0", - "@typescript-eslint/parser": "^5.6.0", - "chai": "^4.3.7", - "eslint": "^8.28.0", - "eslint-plugin-prettier": "^4.2.1", - "glob": "^8.0.3", - "mocha": "^10.1.0", - "prettier": "^2.8.0", - "typescript": "^5.3.3" - } - }, - "../node_modules/@babel/helper-validator-identifier": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", - "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==", - "dev": true - }, - "../node_modules/@babel/highlight": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.12.13.tgz", - "integrity": "sha512-kocDQvIbgMKlWxXe9fof3TQ+gkIPOUSEYhJjqUjvKMez3krV7vbzYCDq39Oj11UAVK7JqPVGQPlgE85dPNlQww==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.12.11", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "../node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - } - }, - "../node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "../node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "../node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "../node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "../node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "../node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - } - }, - "../node_modules/@eslint/eslintrc": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.0.tgz", - "integrity": "sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^12.1.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - } - }, - "../node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "../node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", - "dev": true, - "dependencies": { - "type-fest": "^0.8.1" - } - }, - "../node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "../node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "../node_modules/@nodelib/fs.scandir": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", - "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.4", - "run-parallel": "^1.1.9" - } - }, - "../node_modules/@nodelib/fs.stat": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz", - "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==", - "dev": true - }, - "../node_modules/@nodelib/fs.walk": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz", - "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.4", - "fastq": "^1.6.0" - } - }, - "../node_modules/@types/chai": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.3.tgz", - "integrity": "sha512-VRw2xEGbll3ZiTQ4J02/hUjNqZoue1bMhoo2dgM2LXjDdyaq4q80HgBDHwpI0/VKlo4Eg+BavyQMv/NYgTetzA==", - "dev": true - }, - "../node_modules/@types/json-schema": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", - "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", - "dev": true - }, - "../node_modules/@types/mocha": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", - "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", - "dev": true - }, - "../node_modules/@types/node": { - "version": "7.10.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-7.10.7.tgz", - "integrity": "sha512-4I7+hXKyq7e1deuzX9udu0hPIYqSSkdKXtjow6fMnQ3OR9qkxIErGHbGY08YrfZJrCS1ajK8lOuzd0k3n2WM4A==", - "dev": true - }, - "../node_modules/@typescript-eslint/eslint-plugin": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.22.0.tgz", - "integrity": "sha512-U8SP9VOs275iDXaL08Ln1Fa/wLXfj5aTr/1c0t0j6CdbOnxh+TruXu1p4I0NAvdPBQgoPjHsgKn28mOi0FzfoA==", - "dev": true, - "dependencies": { - "@typescript-eslint/experimental-utils": "4.22.0", - "@typescript-eslint/scope-manager": "4.22.0", - "debug": "^4.1.1", - "functional-red-black-tree": "^1.0.1", - "lodash": "^4.17.15", - "regexpp": "^3.0.0", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - } - }, - "../node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - } - }, - "../node_modules/@typescript-eslint/eslint-plugin/node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - } - }, - "../node_modules/@typescript-eslint/experimental-utils": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.22.0.tgz", - "integrity": "sha512-xJXHHl6TuAxB5AWiVrGhvbGL8/hbiCQ8FiWwObO3r0fnvBdrbWEDy1hlvGQOAWc6qsCWuWMKdVWlLAEMpxnddg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.3", - "@typescript-eslint/scope-manager": "4.22.0", - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/typescript-estree": "4.22.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^2.0.0" - } - }, - "../node_modules/@typescript-eslint/parser": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.22.0.tgz", - "integrity": "sha512-z/bGdBJJZJN76nvAY9DkJANYgK3nlRstRRi74WHm3jjgf2I8AglrSY+6l7ogxOmn55YJ6oKZCLLy+6PW70z15Q==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "4.22.0", - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/typescript-estree": "4.22.0", - "debug": "^4.1.1" - } - }, - "../node_modules/@typescript-eslint/scope-manager": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.22.0.tgz", - "integrity": "sha512-OcCO7LTdk6ukawUM40wo61WdeoA7NM/zaoq1/2cs13M7GyiF+T4rxuA4xM+6LeHWjWbss7hkGXjFDRcKD4O04Q==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/visitor-keys": "4.22.0" - } - }, - "../node_modules/@typescript-eslint/types": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.22.0.tgz", - "integrity": "sha512-sW/BiXmmyMqDPO2kpOhSy2Py5w6KvRRsKZnV0c4+0nr4GIcedJwXAq+RHNK4lLVEZAJYFltnnk1tJSlbeS9lYA==", - "dev": true - }, - "../node_modules/@typescript-eslint/typescript-estree": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.22.0.tgz", - "integrity": "sha512-TkIFeu5JEeSs5ze/4NID+PIcVjgoU3cUQUIZnH3Sb1cEn1lBo7StSV5bwPuJQuoxKXlzAObjYTilOEKRuhR5yg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/visitor-keys": "4.22.0", - "debug": "^4.1.1", - "globby": "^11.0.1", - "is-glob": "^4.0.1", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - } - }, - "../node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - } - }, - "../node_modules/@typescript-eslint/typescript-estree/node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - } - }, - "../node_modules/@typescript-eslint/visitor-keys": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.22.0.tgz", - "integrity": "sha512-nnMu4F+s4o0sll6cBSsTeVsT4cwxB7zECK3dFxzEjPBii9xLpq4yqqsy/FU5zMfan6G60DKZSCXAa3sHJZrcYw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "4.22.0", - "eslint-visitor-keys": "^2.0.0" - } - }, - "../node_modules/@ungap/promise-all-settled": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", - "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", - "dev": true - }, - "../node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true - }, - "../node_modules/acorn-jsx": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", - "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", - "dev": true - }, - "../node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "../node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true - }, - "../node_modules/ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "../node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - } - }, - "../node_modules/anymatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", - "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "../node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "../node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "../node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "../node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true - }, - "../node_modules/balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "../node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "../node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "../node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - } - }, - "../node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "../node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "../node_modules/camelcase": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", - "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", - "dev": true - }, - "../node_modules/chai": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", - "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", - "dev": true, - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "pathval": "^1.1.0", - "type-detect": "^4.0.5" - } - }, - "../node_modules/chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "../node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - } - }, - "../node_modules/check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true - }, - "../node_modules/chokidar": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", - "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.1", - "braces": "~3.0.2", - "glob-parent": "~5.1.0", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.5.0" - } - }, - "../node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "../node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "../node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "../node_modules/cliui/node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "../node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - } - }, - "../node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - } - }, - "../node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "../node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "../node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "../node_modules/debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - } - }, - "../node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "../node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true - }, - "../node_modules/deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - } - }, - "../node_modules/deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", - "dev": true - }, - "../node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true - }, - "../node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - } - }, - "../node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - } - }, - "../node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "../node_modules/enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1" - } - }, - "../node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "../node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "../node_modules/eslint": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.25.0.tgz", - "integrity": "sha512-TVpSovpvCNpLURIScDRB6g5CYu/ZFq9GfX2hLNIV4dSBKxIWojeDODvYl3t0k0VtMxYeR8OXPCFE5+oHMlGfhw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.0", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "enquirer": "^2.3.5", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash": "^4.17.21", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.4", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - } - }, - "../node_modules/eslint-plugin-prettier": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.0.tgz", - "integrity": "sha512-UDK6rJT6INSfcOo545jiaOwB701uAIt2/dR7WnFQoGCVl1/EMqdANBmwUaqqQ45aXprsTGzSa39LI1PyuRBxxw==", - "dev": true, - "dependencies": { - "prettier-linter-helpers": "^1.0.0" - } - }, - "../node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "../node_modules/eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "../node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - }, - "../node_modules/eslint-visitor-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", - "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", - "dev": true - }, - "../node_modules/eslint/node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "../node_modules/eslint/node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "../node_modules/eslint/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "../node_modules/eslint/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "../node_modules/eslint/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "../node_modules/eslint/node_modules/semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - } - }, - "../node_modules/eslint/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - } - }, - "../node_modules/espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, - "dependencies": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" - } - }, - "../node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - }, - "../node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, - "../node_modules/esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - } - }, - "../node_modules/esquery/node_modules/estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", - "dev": true - }, - "../node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - } - }, - "../node_modules/esrecurse/node_modules/estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", - "dev": true - }, - "../node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "../node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "../node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "../node_modules/fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, - "../node_modules/fast-glob": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", - "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", - "merge2": "^1.3.0", - "micromatch": "^4.0.2", - "picomatch": "^2.2.1" - } - }, - "../node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "../node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "../node_modules/fastq": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", - "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "../node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - } - }, - "../node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - } - }, - "../node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "../node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true - }, - "../node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "../node_modules/flatted": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", - "dev": true - }, - "../node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "../node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "../node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "../node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true - }, - "../node_modules/glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "../node_modules/glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - } - }, - "../node_modules/globals": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.8.0.tgz", - "integrity": "sha512-rHtdA6+PDBIjeEvA91rpqzEvk/k3/i7EeNQiryiWuJH0Hw9cpyJMAt2jtbAwUaRdhD+573X4vWw6IcjKPasi9Q==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - } - }, - "../node_modules/globals/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "../node_modules/globby": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz", - "integrity": "sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" - } - }, - "../node_modules/growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, - "../node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "../node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "../node_modules/ignore": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", - "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", - "dev": true - }, - "../node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "../node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "../node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "../node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "../node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - } - }, - "../node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "../node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, - "../node_modules/is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - } - }, - "../node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "../node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true - }, - "../node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "../node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "../node_modules/js-yaml": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", - "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - } - }, - "../node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "../node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "../node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "../node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - } - }, - "../node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "../node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", - "dev": true - }, - "../node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", - "dev": true - }, - "../node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", - "dev": true - }, - "../node_modules/log-symbols": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", - "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0" - } - }, - "../node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - } - }, - "../node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "../node_modules/micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "dev": true, - "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - } - }, - "../node_modules/micromatch/node_modules/picomatch": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", - "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==", - "dev": true - }, - "../node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - } - }, - "../node_modules/mocha": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-8.3.0.tgz", - "integrity": "sha512-TQqyC89V1J/Vxx0DhJIXlq9gbbL9XFNdeLQ1+JsnZsVaSOV1z3tWfw0qZmQJGQRIfkvZcs7snQnZnOCKoldq1Q==", - "dev": true, - "dependencies": { - "@ungap/promise-all-settled": "1.1.2", - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.1", - "debug": "4.3.1", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.1.6", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "4.0.0", - "log-symbols": "4.0.0", - "minimatch": "3.0.4", - "ms": "2.1.3", - "nanoid": "3.1.20", - "serialize-javascript": "5.0.1", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "which": "2.0.2", - "wide-align": "1.1.3", - "workerpool": "6.1.0", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - } - }, - "../node_modules/mocha/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "../node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "../node_modules/nanoid": { - "version": "3.1.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", - "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==", - "dev": true - }, - "../node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "../node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "../node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "../node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "../node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - } - }, - "../node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - } - }, - "../node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - } - }, - "../node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "../node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "../node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "../node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "../node_modules/pathval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", - "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", - "dev": true - }, - "../node_modules/picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "dev": true - }, - "../node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "../node_modules/prettier": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", - "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", - "dev": true - }, - "../node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "dependencies": { - "fast-diff": "^1.1.2" - } - }, - "../node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true - }, - "../node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "../node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "../node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "../node_modules/readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - } - }, - "../node_modules/regexpp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", - "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", - "dev": true - }, - "../node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "../node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true - }, - "../node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "../node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "../node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - } - }, - "../node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "../node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "../node_modules/serialize-javascript": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", - "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "../node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - } - }, - "../node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "../node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "../node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - } - }, - "../node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "../node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true - }, - "../node_modules/string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "dependencies": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "../node_modules/strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "dependencies": { - "ansi-regex": "^3.0.0" - } - }, - "../node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "../node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - } - }, - "../node_modules/table": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.6.0.tgz", - "integrity": "sha512-iZMtp5tUvcnAdtHpZTWLPF0M7AgiQsURR2DwmxnJwSy8I3+cY+ozzVvYha3BOLG2TB+L0CqjIz+91htuj6yCXg==", - "dev": true, - "dependencies": { - "ajv": "^8.0.1", - "lodash.clonedeep": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0" - } - }, - "../node_modules/table/node_modules/ajv": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.2.0.tgz", - "integrity": "sha512-WSNGFuyWd//XO8n/m/EaOlNLtO0yL8EXT/74LqT4khdhpZjP7lkj/kT5uwRmGitKEVp/Oj7ZUHeGfPtgHhQ5CA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - } - }, - "../node_modules/table/node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "../node_modules/table/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "../node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "../node_modules/table/node_modules/string-width": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", - "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "../node_modules/table/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - } - }, - "../node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "../node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - } - }, - "../node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "../node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - } - }, - "../node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "../node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - }, - "../node_modules/typescript": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", - "dev": true - }, - "../node_modules/typescript-template-language-service-decorator": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-2.2.0.tgz", - "integrity": "sha512-xiolqt1i7e22rpqMaprPgSFVgU64u3b9n6EJlAaUYE61jumipKAdI1+O5khPlWslpTUj80YzjUKjJ2jxT0D74w==" - }, - "../node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "../node_modules/v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "../node_modules/vscode-css-languageservice": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-5.1.1.tgz", - "integrity": "sha512-QW0oe/g2y5E2AbVqY7FJNr2Q8uHiAHNSFpptI6xB8Y0KgzVKppOcIVrgmBNzXhFp9IswAwptkdqr8ExSJbqPkQ==", - "dependencies": { - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.16.0", - "vscode-nls": "^5.0.0", - "vscode-uri": "^3.0.2" - } - }, - "../node_modules/vscode-languageserver-textdocument": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz", - "integrity": "sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==" - }, - "../node_modules/vscode-languageserver-types": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz", - "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==" - }, - "../node_modules/vscode-nls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.0.0.tgz", - "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==" - }, - "../node_modules/vscode-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.2.tgz", - "integrity": "sha512-jkjy6pjU1fxUvI51P+gCsxg1u2n8LSt0W6KrCNQceaziKzff74GoWmjVG46KieVzybO1sttPQmYfrwSHey7GUA==" - }, - "../node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - } - }, - "../node_modules/wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, - "dependencies": { - "string-width": "^1.0.2 || 2" - } - }, - "../node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, - "../node_modules/workerpool": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.0.tgz", - "integrity": "sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==", - "dev": true - }, - "../node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "../node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "../node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "../node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "../node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - } - }, - "../node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "../node_modules/y18n": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", - "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", - "dev": true - }, - "../node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "../node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - }, - "../node_modules/yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", - "dev": true - }, - "../node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - } - }, - "../node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "../node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "../node_modules/yargs/node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "../node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.0" - } - }, - "../node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - }, - "node_modules/@styled/typescript-styled-plugin": { - "resolved": "..", - "link": true - }, - "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - }, - "dependencies": { - "@styled/typescript-styled-plugin": { - "version": "file:..", - "requires": { - "@types/chai": "^4.3.4", - "@types/mocha": "^10.0.1", - "@types/node": "^18.11.9", - "@typescript-eslint/eslint-plugin": "^5.6.0", - "@typescript-eslint/parser": "^5.6.0", - "@vscode/emmet-helper": "^2.9.2", - "chai": "^4.3.7", - "eslint": "^8.28.0", - "eslint-plugin-prettier": "^4.2.1", - "glob": "^8.0.3", - "mocha": "^10.1.0", - "prettier": "^2.8.0", - "typescript": "^5.3.3", - "typescript-template-language-service-decorator": "^2.3.2", - "vscode-css-languageservice": "^6.2.11", - "vscode-languageserver-textdocument": "^1.0.11", - "vscode-languageserver-types": "^3.17.5" - }, - "dependencies": { - "@babel/helper-validator-identifier": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", - "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==", - "dev": true - }, - "@babel/highlight": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.12.13.tgz", - "integrity": "sha512-kocDQvIbgMKlWxXe9fof3TQ+gkIPOUSEYhJjqUjvKMez3krV7vbzYCDq39Oj11UAVK7JqPVGQPlgE85dPNlQww==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.12.11", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@eslint/eslintrc": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.0.tgz", - "integrity": "sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^12.1.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - }, - "dependencies": { - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "globals": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", - "dev": true, - "requires": { - "type-fest": "^0.8.1" - } - }, - "ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - } - } - }, - "@nodelib/fs.scandir": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", - "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.4", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz", - "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz", - "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.4", - "fastq": "^1.6.0" - } - }, - "@types/chai": { - "version": "https://registry.npmjs.org/@types/chai/-/chai-4.2.3.tgz", - "integrity": "sha512-VRw2xEGbll3ZiTQ4J02/hUjNqZoue1bMhoo2dgM2LXjDdyaq4q80HgBDHwpI0/VKlo4Eg+BavyQMv/NYgTetzA==", - "dev": true - }, - "@types/json-schema": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", - "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", - "dev": true - }, - "@types/mocha": { - "version": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", - "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", - "dev": true - }, - "@types/node": { - "version": "https://registry.npmjs.org/@types/node/-/node-7.10.7.tgz", - "integrity": "sha512-4I7+hXKyq7e1deuzX9udu0hPIYqSSkdKXtjow6fMnQ3OR9qkxIErGHbGY08YrfZJrCS1ajK8lOuzd0k3n2WM4A==", - "dev": true - }, - "@typescript-eslint/eslint-plugin": { - "version": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.22.0.tgz", - "integrity": "sha512-U8SP9VOs275iDXaL08Ln1Fa/wLXfj5aTr/1c0t0j6CdbOnxh+TruXu1p4I0NAvdPBQgoPjHsgKn28mOi0FzfoA==", - "dev": true, - "requires": { - "@typescript-eslint/experimental-utils": "4.22.0", - "@typescript-eslint/scope-manager": "4.22.0", - "debug": "^4.1.1", - "functional-red-black-tree": "^1.0.1", - "lodash": "^4.17.15", - "regexpp": "^3.0.0", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - }, - "dependencies": { - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "requires": { - "tslib": "^1.8.1" - } - } - } - }, - "@typescript-eslint/experimental-utils": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.22.0.tgz", - "integrity": "sha512-xJXHHl6TuAxB5AWiVrGhvbGL8/hbiCQ8FiWwObO3r0fnvBdrbWEDy1hlvGQOAWc6qsCWuWMKdVWlLAEMpxnddg==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.3", - "@typescript-eslint/scope-manager": "4.22.0", - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/typescript-estree": "4.22.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^2.0.0" - } - }, - "@typescript-eslint/parser": { - "version": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.22.0.tgz", - "integrity": "sha512-z/bGdBJJZJN76nvAY9DkJANYgK3nlRstRRi74WHm3jjgf2I8AglrSY+6l7ogxOmn55YJ6oKZCLLy+6PW70z15Q==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "4.22.0", - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/typescript-estree": "4.22.0", - "debug": "^4.1.1" - } - }, - "@typescript-eslint/scope-manager": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.22.0.tgz", - "integrity": "sha512-OcCO7LTdk6ukawUM40wo61WdeoA7NM/zaoq1/2cs13M7GyiF+T4rxuA4xM+6LeHWjWbss7hkGXjFDRcKD4O04Q==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/visitor-keys": "4.22.0" - } - }, - "@typescript-eslint/types": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.22.0.tgz", - "integrity": "sha512-sW/BiXmmyMqDPO2kpOhSy2Py5w6KvRRsKZnV0c4+0nr4GIcedJwXAq+RHNK4lLVEZAJYFltnnk1tJSlbeS9lYA==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.22.0.tgz", - "integrity": "sha512-TkIFeu5JEeSs5ze/4NID+PIcVjgoU3cUQUIZnH3Sb1cEn1lBo7StSV5bwPuJQuoxKXlzAObjYTilOEKRuhR5yg==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.22.0", - "@typescript-eslint/visitor-keys": "4.22.0", - "debug": "^4.1.1", - "globby": "^11.0.1", - "is-glob": "^4.0.1", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - }, - "dependencies": { - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "requires": { - "tslib": "^1.8.1" - } - } - } - }, - "@typescript-eslint/visitor-keys": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.22.0.tgz", - "integrity": "sha512-nnMu4F+s4o0sll6cBSsTeVsT4cwxB7zECK3dFxzEjPBii9xLpq4yqqsy/FU5zMfan6G60DKZSCXAa3sHJZrcYw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "4.22.0", - "eslint-visitor-keys": "^2.0.0" - } - }, - "@ungap/promise-all-settled": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", - "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", - "dev": true - }, - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", - "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", - "dev": true - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true - }, - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", - "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true - }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", - "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", - "dev": true - }, - "chai": { - "version": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", - "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "pathval": "^1.1.0", - "type-detect": "^4.0.5" - } - }, - "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true - }, - "chokidar": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", - "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", - "dev": true, - "requires": { - "anymatch": "~3.1.1", - "braces": "~3.0.2", - "glob-parent": "~5.1.0", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.5.0" - } - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "debug": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", - "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true - }, - "deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, - "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", - "dev": true - }, - "diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, - "requires": { - "ansi-colors": "^4.1.1" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "https://registry.npmjs.org/eslint/-/eslint-7.25.0.tgz", - "integrity": "sha512-TVpSovpvCNpLURIScDRB6g5CYu/ZFq9GfX2hLNIV4dSBKxIWojeDODvYl3t0k0VtMxYeR8OXPCFE5+oHMlGfhw==", - "dev": true, - "requires": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.0", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "enquirer": "^2.3.5", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash": "^4.17.21", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.4", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "requires": { - "@babel/highlight": "^7.10.4" - } - }, - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "eslint-plugin-prettier": { - "version": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.0.tgz", - "integrity": "sha512-UDK6rJT6INSfcOo545jiaOwB701uAIt2/dR7WnFQoGCVl1/EMqdANBmwUaqqQ45aXprsTGzSa39LI1PyuRBxxw==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.0" - } - }, - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - } - } - }, - "eslint-visitor-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", - "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", - "dev": true - }, - "espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, - "requires": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - } - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, - "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - }, - "dependencies": { - "estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", - "dev": true - } - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - }, - "dependencies": { - "estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", - "dev": true - } - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, - "fast-glob": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", - "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.0", - "merge2": "^1.3.0", - "micromatch": "^4.0.2", - "picomatch": "^2.2.1" - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "fastq": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", - "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", - "dev": true - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true - }, - "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "globals": { - "version": "13.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.8.0.tgz", - "integrity": "sha512-rHtdA6+PDBIjeEvA91rpqzEvk/k3/i7EeNQiryiWuJH0Hw9cpyJMAt2jtbAwUaRdhD+573X4vWw6IcjKPasi9Q==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - }, - "dependencies": { - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - } - } - }, - "globby": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz", - "integrity": "sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" - } - }, - "growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "ignore": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", - "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, - "is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", - "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", - "dev": true - }, - "lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", - "dev": true - }, - "lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", - "dev": true - }, - "log-symbols": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", - "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", - "dev": true, - "requires": { - "chalk": "^4.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "dev": true, - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - }, - "dependencies": { - "picomatch": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", - "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==", - "dev": true - } - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "mocha": { - "version": "https://registry.npmjs.org/mocha/-/mocha-8.3.0.tgz", - "integrity": "sha512-TQqyC89V1J/Vxx0DhJIXlq9gbbL9XFNdeLQ1+JsnZsVaSOV1z3tWfw0qZmQJGQRIfkvZcs7snQnZnOCKoldq1Q==", - "dev": true, - "requires": { - "@ungap/promise-all-settled": "1.1.2", - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.1", - "debug": "4.3.1", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.1.6", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "4.0.0", - "log-symbols": "4.0.0", - "minimatch": "3.0.4", - "ms": "2.1.3", - "nanoid": "3.1.20", - "serialize-javascript": "5.0.1", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "which": "2.0.2", - "wide-align": "1.1.3", - "workerpool": "6.1.0", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - }, - "dependencies": { - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "nanoid": { - "version": "3.1.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", - "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "pathval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", - "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", - "dev": true - }, - "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", - "dev": true - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prettier": { - "version": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", - "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", - "dev": true - }, - "prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", - "dev": true, - "requires": { - "picomatch": "^2.2.1" - } - }, - "regexpp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", - "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", - "dev": true - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "serialize-javascript": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", - "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "dependencies": { - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - } - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "table": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.6.0.tgz", - "integrity": "sha512-iZMtp5tUvcnAdtHpZTWLPF0M7AgiQsURR2DwmxnJwSy8I3+cY+ozzVvYha3BOLG2TB+L0CqjIz+91htuj6yCXg==", - "dev": true, - "requires": { - "ajv": "^8.0.1", - "lodash.clonedeep": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ajv": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.2.0.tgz", - "integrity": "sha512-WSNGFuyWd//XO8n/m/EaOlNLtO0yL8EXT/74LqT4khdhpZjP7lkj/kT5uwRmGitKEVp/Oj7ZUHeGfPtgHhQ5CA==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "string-width": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", - "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - }, - "typescript": { - "version": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", - "dev": true - }, - "typescript-template-language-service-decorator": { - "version": "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-2.2.0.tgz", - "integrity": "sha512-xiolqt1i7e22rpqMaprPgSFVgU64u3b9n6EJlAaUYE61jumipKAdI1+O5khPlWslpTUj80YzjUKjJ2jxT0D74w==" - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "vscode-css-languageservice": { - "version": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-5.1.1.tgz", - "integrity": "sha512-QW0oe/g2y5E2AbVqY7FJNr2Q8uHiAHNSFpptI6xB8Y0KgzVKppOcIVrgmBNzXhFp9IswAwptkdqr8ExSJbqPkQ==", - "requires": { - "vscode-languageserver-textdocument": "^1.0.1", - "vscode-languageserver-types": "^3.16.0", - "vscode-nls": "^5.0.0", - "vscode-uri": "^3.0.2" - } - }, - "vscode-languageserver-textdocument": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz", - "integrity": "sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==" - }, - "vscode-languageserver-types": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz", - "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==" - }, - "vscode-nls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.0.0.tgz", - "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==" - }, - "vscode-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.2.tgz", - "integrity": "sha512-jkjy6pjU1fxUvI51P+gCsxg1u2n8LSt0W6KrCNQceaziKzff74GoWmjVG46KieVzybO1sttPQmYfrwSHey7GUA==" - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2" - } - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true - }, - "workerpool": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.0.tgz", - "integrity": "sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==", - "dev": true - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "y18n": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", - "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", - "dev": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "dependencies": { - "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.0" - } - } - } - }, - "yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", - "dev": true - }, - "yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "requires": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - } - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - } - } - }, - "typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==" - } - } -} diff --git a/e2e/package.json b/e2e/package.json deleted file mode 100644 index be43791..0000000 --- a/e2e/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "e2e", - "private": true, - "version": "1.0.0", - "description": "", - "main": "index.js", - "directories": { - "test": "tests" - }, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "typescript": "^5.3.3", - "@styled/typescript-styled-plugin": "file:./.." - } -} diff --git a/e2e/project-fixture/.vscode/settings.json b/e2e/project-fixture/.vscode/settings.json deleted file mode 100644 index 55712c1..0000000 --- a/e2e/project-fixture/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file diff --git a/e2e/project-fixture/index.ts b/e2e/project-fixture/index.ts deleted file mode 100644 index 525db1e..0000000 --- a/e2e/project-fixture/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -css` - -` \ No newline at end of file diff --git a/e2e/project-fixture/main.ts b/e2e/project-fixture/main.ts deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/project-fixture/tsconfig.json b/e2e/project-fixture/tsconfig.json deleted file mode 100644 index 22de2f0..0000000 --- a/e2e/project-fixture/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "commonjs", - "plugins": [{ "name": "@styled/typescript-styled-plugin", "tags": ["css", "keyframes"] }] - } -} diff --git a/e2e/server-fixture/index.js b/e2e/server-fixture/index.js deleted file mode 100644 index fa2e5fb..0000000 --- a/e2e/server-fixture/index.js +++ /dev/null @@ -1,86 +0,0 @@ -const { fork } = require('child_process'); -const path = require('path'); -const readline = require('readline'); - -class TSServer { - constructor(project) { - const logfile = path.join(__dirname, 'log.txt'); - const tsserverPath = path.join(__dirname, '..', 'node_modules', 'typescript', 'lib', 'tsserver'); - const server = fork(tsserverPath, [ - '--logVerbosity', 'verbose', - '--logFile', logfile, - '--pluginProbeLocations', path.join(__dirname, '..') - ], { - cwd: path.join(__dirname, '..', project), - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - }); - this._exitPromise = new Promise((resolve, reject) => { - server.on('exit', code => resolve(code)); - server.on('error', reason => reject(reason)); - }); - server.stdout.setEncoding('utf-8'); - readline.createInterface({ - input: server.stdout - }).on('line', line => { - if (line[0] !== '{') { - return; - } - try { - const result = JSON.parse(line); - if (result.type === 'response') { - this.responses.push(result); - --this._pendingResponses; - - if (this._pendingResponses <= 0 && this._isClosed) { - this._shutdown(); - } - } - } catch (e) { - // noop - } - - }); - - this._isClosed = false; - this._server = server; - this._seq = 0; - this.responses = []; - this._pendingResponses = 0; - } - - send(command, responseExpected) { - if (this._isClosed) { - throw new Error('server is closed'); - } - if (responseExpected) { - ++this._pendingResponses; - } - const seq = ++this._seq; - const req = JSON.stringify(Object.assign({ seq: seq, type: 'request' }, command)) + '\n'; - this._server.stdin.write(req); - } - - sendCommand(name, args) { - this.send({ command: name, arguments: args }, true); - } - - close() { - if (!this._isClosed) { - this._isClosed = true; - if (this._pendingResponses <= 0) { - this._shutdown(); - } - } - return this._exitPromise; - } - - _shutdown() { - this._server.stdin.end(); - } -} - -function createServer(project) { - return new TSServer(project || 'project-fixture'); -} - -module.exports = createServer; diff --git a/e2e/tests/_helpers.js b/e2e/tests/_helpers.js deleted file mode 100644 index d17e779..0000000 --- a/e2e/tests/_helpers.js +++ /dev/null @@ -1,24 +0,0 @@ -const assert = require('chai').assert; - -exports.openMockFile = (server, mockFileName, fileContent) => { - server.send({ - command: 'open', - arguments: { - file: mockFileName, - fileContent, - scriptKindName: 'TS' - } - }); - return server; -}; - - -exports.getFirstResponseOfType = (command, server) => { - const response = server.responses.find(response => response.command === command); - assert.isTrue(response !== undefined); - return response; -}; - -exports.getResponsesOfType = (command, server) => { - return server.responses.filter(response => response.command === command); -}; \ No newline at end of file diff --git a/e2e/tests/completionEntryDetails.js b/e2e/tests/completionEntryDetails.js deleted file mode 100644 index ead1156..0000000 --- a/e2e/tests/completionEntryDetails.js +++ /dev/null @@ -1,24 +0,0 @@ -const assert = require('chai').assert; -const path = require('path'); -const createServer = require('../server-fixture'); -const { openMockFile, getFirstResponseOfType } = require('./_helpers'); - -const mockFileName = path.join(__dirname, '..', 'project-fixture', 'main.ts'); - -describe('CompletionEntryDetails', () => { - it('should return details for color completion', () => { - const server = createServer(); - openMockFile(server, mockFileName, 'const q = css`color:`'); - server.sendCommand('completionEntryDetails', { file: mockFileName, offset: 21, line: 1, entryNames: ['blue'] }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completionEntryDetails', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 1); - - const firstDetails = completionsResponse.body[0] - assert.strictEqual(firstDetails.name, 'blue'); - assert.strictEqual(firstDetails.documentation[0].text, '#0000ff'); - }); - }); -}); diff --git a/e2e/tests/completions.js b/e2e/tests/completions.js deleted file mode 100644 index ca1625a..0000000 --- a/e2e/tests/completions.js +++ /dev/null @@ -1,249 +0,0 @@ -const assert = require('chai').assert; -const path = require('path'); -const createServer = require('../server-fixture'); -const { openMockFile, getFirstResponseOfType } = require('./_helpers'); - -const mockFileName = path.join(__dirname, '..', 'project-fixture', 'main.ts'); - -const createServerWithMockFile = (fileContents) => { - const server = createServer(); - openMockFile(server, mockFileName, fileContents); - return server; -}; - -describe('Completions', () => { - it('should return property value completions for single line string', () => { - const server = createServerWithMockFile('const q = css`color:`'); - server.sendCommand('completions', { file: mockFileName, offset: 21, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'rgba')); - }); - }); - - it('should not return SCSS functions in property value completions', () => { - const server = createServerWithMockFile('const q = css`color:`'); - server.sendCommand('completions', { file: mockFileName, offset: 21, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isFalse(completionsResponse.body.some((item) => item.name === 'darken')); - }); - }); - - it('should return property value completions for multiline string', () => { - const server = createServerWithMockFile(['const q = css`', 'color:', '`'].join('\n')); - server.sendCommand('completions', { file: mockFileName, offset: 22, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return property value completions for nested selector', () => { - const server = createServerWithMockFile('const q = css`position: relative; &:hover { color: }`'); - server.sendCommand('completions', { file: mockFileName, offset: 51, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should not return css completions on tag', () => { - const server = createServerWithMockFile('css.``'); - server.sendCommand('completions', { file: mockFileName, offset: 5, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isFalse(completionsResponse.success); - assert.strictEqual(completionsResponse.message, 'No content available.'); - }); - }); - - it('should return completions when placeholder is used as property', () => { - const server = createServerWithMockFile('css`color: ; boarder: 1px solid ${"red"};`'); - server.sendCommand('completions', { file: mockFileName, offset: 11, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return completions after where placeholder is used as property', () => { - const server = createServerWithMockFile('css`border: 1px solid ${"red"}; color:`'); - server.sendCommand('completions', { file: mockFileName, offset: 39, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return completions between were placeholders are used as properties', () => { - const server = createServerWithMockFile('css`boarder: 1px solid ${"red"}; color: ; margin: ${20}; `'); - server.sendCommand('completions', { file: mockFileName, offset: 40, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return completions on tagged template string with placeholder using dotted tag', () => { - const server = createServerWithMockFile('css.x`color: ; boarder: 1px solid ${"red"};`'); - server.sendCommand('completions', { file: mockFileName, offset: 13, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return js completions inside placeholder', () => { - const server = createServerWithMockFile('const abc = 123; css`color: ${};`'); - server.sendCommand('completions', { file: mockFileName, offset: 31, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'abc')); - }); - }); - - it('should return js completions at end of placeholder', () => { - const server = createServerWithMockFile('css`color: ${"red".};`'); - server.sendCommand('completions', { file: mockFileName, offset: 20, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'substr')); - }); - }); - - it('should return styled completions inside of nested placeholder', () => { - const server = createServerWithMockFile('styled`background: red; ${(() => css`color:`)()}`;'); - server.sendCommand('completions', { file: mockFileName, offset: 44, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should handle multiline value placeholder correctly ', () => { - const server = createServerWithMockFile(['css`margin: ${', '0', '}; color: `'].join('\n')); - server.sendCommand('completions', { file: mockFileName, offset: 10, line: 3 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should handle multiline rule placeholder correctly ', () => { - const server = createServerWithMockFile(['css`', '${', 'css`margin: 0;`', '}', 'color: `'].join('\n')); - server.sendCommand('completions', { file: mockFileName, offset: 8, line: 5 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return completions when placeholder is used as a selector', () => { - const server = createServerWithMockFile(['css`${"button"} {', ' color: ;', '}', 'color: ;', '`'].join('\n')); - server.sendCommand('completions', { file: mockFileName, line: 2, offset: 11 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should return completions inside of nested selector xx', () => { - const server = createServerWithMockFile( - ['css`', ' color: red;', ' &:hover {', ' color: ', ' }', '`'].join('\n') - ); - server.sendCommand('completions', { file: mockFileName, line: 4, offset: 15 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - }); - }); - - it('should support tag that is a function call', () => { - const server = createServerWithMockFile('const q = css("bla")`color:`'); - server.sendCommand('completions', { file: mockFileName, offset: 28, line: 1 }); - - return server.close().then(() => { - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'rgba')); - }); - }); - - it('should support tag that is a templated function call', async () => { - const server = createServerWithMockFile("const q = css('bla')`color:`"); - server.sendCommand('completions', { file: mockFileName, offset: 36, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.strictEqual(completionsResponse.body.length, 175); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'aliceblue')); - assert.isTrue(completionsResponse.body.some((item) => item.name === 'rgba')); - }); - - it('should mark color completions with "color" kindModifier', async () => { - const server = createServerWithMockFile('const q = css`color:`'); - server.sendCommand('completions', { file: mockFileName, offset: 21, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - const aliceBlue = completionsResponse.body.find((item) => item.name === 'aliceblue'); - assert.isTrue(aliceBlue.kindModifiers === 'color'); - }); - - it('should get completions inside keyframes blocks', async () => { - const server = createServerWithMockFile('const q = keyframes`0% {color:`'); - server.sendCommand('completions', { file: mockFileName, offset: 31, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - const aliceBlue = completionsResponse.body.find((item) => item.name === 'aliceblue'); - assert.isTrue(aliceBlue.kindModifiers === 'color'); - }); -}); diff --git a/e2e/tests/emmetCompletions.js b/e2e/tests/emmetCompletions.js deleted file mode 100644 index 22f73c4..0000000 --- a/e2e/tests/emmetCompletions.js +++ /dev/null @@ -1,155 +0,0 @@ -// @ts-check -const assert = require('chai').assert; -const path = require('path'); -const createServer = require('../server-fixture'); -const { openMockFile, getFirstResponseOfType } = require('./_helpers'); - -const createMockFileForServer = (fileContents, project) => { - project = project || 'project-fixture' - const server = createServer(project); - const mockFileName = path.join(__dirname, '..', project, 'main.ts'); - openMockFile(server, mockFileName, fileContents); - return { server, mockFileName }; -} - -describe('Emmet Completions', () => { - it('shouldnt return emmet property completions when disabled', async () => { - const { server, mockFileName } = createMockFileForServer( - 'const q = css`m10-20`', 'disabled-emmet-project-fixture'); - server.sendCommand('completions', { file: mockFileName, offset: 21, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.every(item => item.name !== 'margin: 10px 20px;')); - }); - - it('should return emmet property completions for single line string', async () => { - const { server, mockFileName } = createMockFileForServer('const q = css`m10-20`'); - server.sendCommand('completions', { file: mockFileName, offset: 21, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); - }); - - it('should return emmet property completions for multiline string', async () => { - const { server, mockFileName } = createMockFileForServer([ - 'const q = css`', - 'm10-20', - '`' - ].join('\n')); - server.sendCommand('completions', { file: mockFileName, offset: 7, line: 2 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); - }); - - it('should return emmet property completions for nested selector', async () => { - const { server, mockFileName } = createMockFileForServer( - 'const q = css`position: relative; &:hover { m10-20 }`'); - - server.sendCommand('completions', { file: mockFileName, offset: 51, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); - }); - - it('should return emmet completions when placeholder is used as property', async () => { - const { server, mockFileName } = createMockFileForServer('css`m10-20 ; boarder: 1px solid ${"red"};`'); - server.sendCommand('completions', { file: mockFileName, offset: 11, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); - }); - - it('should return emmet completions after where placeholder is used as property', async () => { - const { server, mockFileName } = createMockFileForServer('css`border: 1px solid ${"red"}; m10-20`'); - server.sendCommand('completions', { file: mockFileName, offset: 39, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); - }); - - it('should return emmet completions between were placeholders are used as properties', async () => { - const { server, mockFileName } = createMockFileForServer('css`boarder: 1px solid ${"red"}; color: #12; margin: ${20}; `') - server.sendCommand('completions', { file: mockFileName, offset: 44, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); - }); - - it('should return emmet completions on tagged template string with placeholder using dotted tag', async () => { - const { server, mockFileName } = createMockFileForServer('css.x`color: #12 ; boarder: 1px solid ${"red"};`'); - server.sendCommand('completions', { file: mockFileName, offset: 17, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.success); - assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); - }); - - it('should return styled emmet completions inside of nested placeholder', async () => { - const { server, mockFileName } = createMockFileForServer('styled`background: red; ${(() => css`color: #12`)()}`;'); - server.sendCommand('completions', { file: mockFileName, offset: 48, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); - }); - - it('should handle emmet completions in multiline value placeholder correctly ', async () => { - const { server, mockFileName } = createMockFileForServer([ - 'css`margin: ${', - '0', - "}; color: #12`"].join('\n')); - server.sendCommand('completions', { file: mockFileName, offset: 14, line: 3 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); - }); - - it('should handle emmet completions in multiline rule placeholder correctly ', async () => { - const { server, mockFileName } = createMockFileForServer([ - 'css`', - '${', - 'css`margin: 0;`', - '}', - 'color: #12`'].join('\n')); - server.sendCommand('completions', { file: mockFileName, offset: 11, line: 5 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); - }); - - it('should return emmet completions inside of nested selector xx', async () => { - const { server, mockFileName } = createMockFileForServer([ - 'css`', - ' color: red;', - ' &:hover {', - ' color: #12 ', - ' }', - '`'].join('\n')); - server.sendCommand('completions', { file: mockFileName, line: 4, offset: 19 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); - }); - - it('should mark emmet completions as isIncomplete', async () => { - const { server, mockFileName } = createMockFileForServer('const q = css`m10-20`'); - server.sendCommand('completions', { file: mockFileName, offset: 21, line: 1 }); - - await server.close(); - const completionsResponse = getFirstResponseOfType('completions', server); - assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); - assert.isTrue(completionsResponse.metadata.isIncomplete); - }); -}); diff --git a/e2e/tests/errors.js b/e2e/tests/errors.js deleted file mode 100644 index 15fe7c4..0000000 --- a/e2e/tests/errors.js +++ /dev/null @@ -1,402 +0,0 @@ -// @ts-check -const assert = require('chai').assert; -const path = require('path'); -const createServer = require('../server-fixture'); -const { openMockFile, getFirstResponseOfType } = require('./_helpers'); - -const mockFileName = path.join(__dirname, '..', 'project-fixture', 'main.ts'); - -const getSemanticDiagnosticsForFile = (fileContents) => { - const server = createServer(); - openMockFile(server, mockFileName, fileContents); - server.sendCommand('semanticDiagnosticsSync', { file: mockFileName }); - - return server.close().then(_ => { - return getFirstResponseOfType('semanticDiagnosticsSync', server); - }); -} - -describe('Errors', () => { - it('should return error for unknown property', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(x) { return x; }; const q = css`boarder: 1px solid black;`'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 1); - const error = errorResponse.body[0]; - assert.strictEqual(error.text, "Unknown property: 'boarder'"); - assert.strictEqual(error.start.line, 1); - assert.strictEqual(error.start.offset, 46); - assert.strictEqual(error.end.line, 1); - assert.strictEqual(error.end.offset, 53); - }); - - it('should not return errors for empty rulesets', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(x) { return x; }; const q = css``'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return errors for nested rulesets', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(x) { return x; }; const q = css`&:hover { border: 1px solid black; }`'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for a placeholder in a property', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(strings, ...) { return ""; }; const q = css`color: ${"red"};`'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for a placeholder in a property with a multiline string', async () => { - const errorResponse = await getSemanticDiagnosticsForFile([ - 'function css(strings, ...) { return ""; }; const q = css`', - ' color: ${"red"};', - '`' - ].join('\n')); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should return errors when error occurs in last position', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(strings, ...) { return ""; }; const q = css`;`'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 1); - const error = errorResponse.body[0]; - assert.strictEqual(error.text, '} expected'); - assert.strictEqual(error.start.line, 1); - assert.strictEqual(error.start.offset, 58); - assert.strictEqual(error.end.line, 1); - assert.strictEqual(error.end.offset, 59); - }); - - it('should return error for multiline unknown property #20', async () => { - const errorResponse = await getSemanticDiagnosticsForFile([ - 'function css(x) { return x; };', - 'const q = css`', - 'boarder: 1px solid black;', - '`' - ].join('\n')); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 1); - const error = errorResponse.body[0]; - assert.strictEqual(error.text, "Unknown property: 'boarder'"); - assert.strictEqual(error.start.line, 3); - assert.strictEqual(error.start.offset, 1); - assert.strictEqual(error.end.line, 3); - assert.strictEqual(error.end.offset, 8); - }); - - it('should not error with interpolation at start, followed by semicolon #22', async () => { - const errorResponse = await getSemanticDiagnosticsForFile([ - "function css(...args){}", - "const mixin = ''", - // test single-line - "css`${mixin}; color: blue;`", - // test multi-line (normal case) - "css`", - " ${mixin};", - " color: blue;", - "`", - // test multiple spaces after semi - "css`", - " ${mixin} ;", - " color: blue;", - "`", - // test hella semis - will this ever pop up? probably not, but screw it - "css`", - " ${mixin};;; ;; ;", - " color: blue;", - "`", - ].join('\n')); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for a placeholder used as a selector (#30)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(strings, ...) { return ""; }; const q = css`${"button"} { color: red; }`'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for a placeholder used as a complex selector (#30)', () => { - return getSemanticDiagnosticsForFile(` - function css(strings, ...) { return ""; }; - function fullWidth() { }; - const Button = {}; - const q = css\` - display: flex; - \${fullWidth()}; - - \${Button} { - width: 100%; - - &:not(:first-child):not(:last-child) { - margin-left: 0; - margin-right: 0; - border-radius: 0; - } - } - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for a placeholder used as selector part (#39)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile('function css(strings, ...) { return ""; }; const Content = "button"; const q = css`& > ${Content} { margin-left: 1px; }`'); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for a placeholder in multiple properties (#39)', () => { - return getSemanticDiagnosticsForFile( - `function css(strings, ...) { return ""; }; const Content = "button"; const q = css\` - & > $\{'content'} { - color: 1px; - } - - & > $\{'styledNavBar'} { - margin-left: $\{1}; - } - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for a placeholder that spans multiple lines aaa (#44)', () => { - return getSemanticDiagnosticsForFile( - `let css: any = {}; const q = css.a\` - color: - $\{'transparent'}; - border-bottom: 1px; - &:hover { - color: inherit; - text-decoration: none; - } - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for complicated style (#44)', () => { - return getSemanticDiagnosticsForFile( - `let css: any = {}; const q = css.a\` - display: flex; - width: 6rem; - height: 5rem; - margin-right: -3px; - border-right: 3px solid - $\{({ active, theme: { colors } }) => - active ? colors.yellow : 'transparent'}; - border-bottom: 1px solid rgba(255, 255, 255, 0.5); - font-weight: bold; - font-size: 0.875rem; - color: white; - cursor: pointer; - &:not([href]):not([tabindex]) { - color: white; - } - &:hover { - color: inherit; - text-decoration: none; - } -\`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for a placeholder value followed by unit (#48)', () => { - return getSemanticDiagnosticsForFile( - `function css(strings, ...) { return ""; }; const value = 1; const q = css\` - width: $\{value}%; - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for a placeholder as the declaration name (#52)', () => { - return getSemanticDiagnosticsForFile( - `function css(strings, ...) { return ""; }; const q = css\` - $\{'width'}: 1px; - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for a placeholder as part of a rule (#59)', () => { - return getSemanticDiagnosticsForFile( - `function css(strings, ...) { return ""; }; const q = css\` - $\{'a'}, \${'button'} { - width: 1px; - } - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error placeholder used as entire property within nested (#54)', () => { - return getSemanticDiagnosticsForFile( - `function css(strings, ...) { return ""; }; const q = css\` - &.buu-foo { - \${'baseShape'}; - &.active { - font-size: 2rem; - } - } - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error on adjacent variables (#62)', () => { - return getSemanticDiagnosticsForFile( - `let css: any = {}; const margin1 = "3px"; const margin2 = "3px"; const q = css.a\` - margin: $\{margin1} $\{margin2}; - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for contextual selector (#71)', () => { - return getSemanticDiagnosticsForFile( - `let css: any = {}; const q = css.a\` - html.test & { - display: none; - } - \`` - ).then(errorResponse => { - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - }); - - it('should not return an error for placeholder used in contextual selector (#71)', async () => { - { - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; let FlipContainer = 'button'; const q = css.a\` - position: relative; - - $\{FlipContainer}:hover & { - transform: rotateY(180deg); - } - \``); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - } - { - // #67 part 1 - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; let OtherStyledElm = 'button'; const q = css.a\` - \${OtherStyledElm}:not([value=""]) + & { - transform: rotateY(180deg); - } - \``); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - } - { - // #67 part 2 - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; let OtherStyledElm = 'button'; const q = css.a\` - \${OtherStyledElm} + &, - \${OtherStyledElm}:not([value=""]) + & { - transform: rotateY(180deg); - } - \``); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - } - }); - - it('should not return an error for custom function (#21)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile( - `function css(): any {}; const q = css<{}>()(window.blur)\` - display: none; - \``); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for empty sub-rulesets (#50)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; const q = css.a\` - :nth-of-type(1) { - \${true ? "display: initial" : "display: hidden"} - } - \``); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error (#74)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; - const ListNoteItem = 'bla'; - const ListNoteTitle = css.span\` - font-weight: bold; - color: \${props => props.theme.primaryColor}; - \${ListNoteItem}:hover & { - text-decoration: underline; - } - \`;`); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should not return an error for child selector (#75)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; - const ListNoteItem = 'bla'; - const ListNoteTitle = css.span\` - width: 100%; - > \${ListNoteItem}:hover { - color: red; - } - \`;`); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); - - it('should include error for unknown property in selector', async () => { - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; - const ListNoteItem = 'bla'; - const ListNoteTitle = css.span\` - width: 100%; - &:hover { - noSuch: red; - } - \`;`); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 1); - }); - - it('should not error for newer properties(#95, #53)', async () => { - const errorResponse = await getSemanticDiagnosticsForFile( - `let css: any = {}; - const ListNoteTitle = css.span\` - scrollbar-width: 10px; - scrollbar-color: red; - scroll-snap-align: initial; - \`;`); - assert.isTrue(errorResponse.success); - assert.strictEqual(errorResponse.body.length, 0); - }); -}); diff --git a/e2e/tests/outliningSpans.js b/e2e/tests/outliningSpans.js deleted file mode 100644 index 964cd16..0000000 --- a/e2e/tests/outliningSpans.js +++ /dev/null @@ -1,45 +0,0 @@ -//@ts-check -const path = require('path'); -const assert = require('chai').assert; -const createServer = require('../server-fixture'); -const { openMockFile, getFirstResponseOfType } = require('./_helpers'); - -const mockFileName = path.join(__dirname, '..', 'project-fixture', 'main.ts'); - -describe('OutliningSpans', () => { - it('should return basic css outlining spans', async () => { - const spans = await getOutlingSpansForMockFile([ - 'const q = css`', - 'a {', - 'color: red;', - '}', - 'div {', - '', - '}', - '`' - ].join('\n')); - - assert.strictEqual(spans.length, 3); - - // The first span represents the root - const [, span2, span3] = spans; - assertPosition(span2.textSpan.start, 2, 1); - assertPosition(span2.textSpan.end, 3, 1); - - assertPosition(span3.textSpan.start, 5, 1); - assertPosition(span3.textSpan.end, 6, 1); - }); -}); - -function getOutlingSpansForMockFile(contents) { - const server = createServer(); - openMockFile(server, mockFileName, contents); - server.sendCommand('getOutliningSpans', { file: mockFileName }); - - return server.close().then(() => getFirstResponseOfType('getOutliningSpans', server).body); -} - -function assertPosition(pos, line, offset) { - assert.strictEqual(pos.line, line); - assert.strictEqual(pos.offset, offset); -} \ No newline at end of file diff --git a/e2e/tests/quickFix.js b/e2e/tests/quickFix.js deleted file mode 100644 index 3116f03..0000000 --- a/e2e/tests/quickFix.js +++ /dev/null @@ -1,85 +0,0 @@ -// @ts-check -const assert = require('chai').assert; -const path = require('path'); -const createServer = require('../server-fixture'); -const { openMockFile, getFirstResponseOfType, getResponsesOfType } = require('./_helpers'); - -const mockFileName = path.join(__dirname, '..', 'project-fixture', 'main.ts'); - -describe('QuickFix', () => { - it('should return quickFix for misspelled properties fooa', () => { - const server = createServer(); - openMockFile(server, mockFileName, 'const q = css`boarder: 1px solid black;`'); - server.sendCommand('getCodeFixes', { - file: mockFileName, - startLine: 1, - startOffset: 16, - endLine: 1, - endOffset: 16, - errorCodes: [9999] - }); - - return server.close().then(() => { - const response = getFirstResponseOfType('getCodeFixes', server); - assert.isTrue(response.success); - assert.strictEqual(response.body.length, 3); - assert.isOk(response.body.find(fix => fix.description === 'Rename to \'border\'')); - }); - }); - - it('should not return quickFixes for correctly spelled properties', () => { - const server = createServer(); - openMockFile(server, mockFileName, 'const q = css`border: 1px solid black;`'); - server.sendCommand('getCodeFixes', { - file: mockFileName, - startLine: 1, - startOffset: 16, - endLine: 1, - endOffset: 16, - errorCodes: [9999] - }); - - return server.close().then(() => { - const response = getFirstResponseOfType('getCodeFixes', server); - assert.isTrue(response.success); - assert.strictEqual(response.body.length, 0); - }); - }); - - it('should only return spelling quickFix when range includes misspelled property', () => { - const server = createServer(); - openMockFile(server, mockFileName, 'const q = css`boarder: 1px solid black;`'); - server.sendCommand('getCodeFixes', { - file: mockFileName, - startLine: 1, - startOffset: 14, - endLine: 1, - endOffset: 14, - errorCodes: [9999] - }); - - server.sendCommand('getCodeFixes', { - file: mockFileName, - startLine: 1, - startOffset: 22, - endLine: 1, - endOffset: 22, - errorCodes: [9999] - }); - - return server.close().then(() => { - const responses = getResponsesOfType('getCodeFixes', server); - assert.strictEqual(responses.length, 2); - { - const response = responses[0] - assert.isTrue(response.success); - assert.strictEqual(response.body.length, 0); - } - { - const response = responses[1] - assert.isTrue(response.success); - assert.strictEqual(response.body.length, 0); - } - }); - }); -}); diff --git a/test/e2e/closed-file-project-fixture/entry.ts b/test/e2e/closed-file-project-fixture/entry.ts new file mode 100644 index 0000000..f710f58 --- /dev/null +++ b/test/e2e/closed-file-project-fixture/entry.ts @@ -0,0 +1 @@ +import './unclosed' diff --git a/test/e2e/closed-file-project-fixture/tsconfig.json b/test/e2e/closed-file-project-fixture/tsconfig.json new file mode 100644 index 0000000..aa12681 --- /dev/null +++ b/test/e2e/closed-file-project-fixture/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowJs": true, + "jsx": "preserve", + "noEmit": true, + "target": "ES2021", + "module": "commonjs", + "strict": true, + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "tags": ["css"] + } + ] + } +} diff --git a/test/e2e/closed-file-project-fixture/unclosed.ts b/test/e2e/closed-file-project-fixture/unclosed.ts new file mode 100644 index 0000000..a98e917 --- /dev/null +++ b/test/e2e/closed-file-project-fixture/unclosed.ts @@ -0,0 +1,8 @@ +function css(strings: TemplateStringsArray) { + return strings +} + +export const bad = css` + a { + color: red; +` \ No newline at end of file diff --git a/test/e2e/default-tags-project-fixture/main.ts b/test/e2e/default-tags-project-fixture/main.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/default-tags-project-fixture/main.ts @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/default-tags-project-fixture/tsconfig.json b/test/e2e/default-tags-project-fixture/tsconfig.json new file mode 100644 index 0000000..df15ec8 --- /dev/null +++ b/test/e2e/default-tags-project-fixture/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowJs": true, + "jsx": "preserve", + "noEmit": true, + "target": "ES2021", + "module": "commonjs", + "strict": true, + "plugins": [ + { + "name": "@styled/typescript-styled-plugin" + } + ] + } +} diff --git a/test/e2e/emmet-disabled-project-fixture/main.ts b/test/e2e/emmet-disabled-project-fixture/main.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/emmet-disabled-project-fixture/main.ts @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/emmet-disabled-project-fixture/tsconfig.json b/test/e2e/emmet-disabled-project-fixture/tsconfig.json new file mode 100644 index 0000000..5d7e5c2 --- /dev/null +++ b/test/e2e/emmet-disabled-project-fixture/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2015", + "module": "commonjs", + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "tags": ["css"], + "emmet": { "showExpandedAbbreviation": "never" } + } + ] + } +} diff --git a/test/e2e/fixture-paths.ts b/test/e2e/fixture-paths.ts new file mode 100644 index 0000000..e0d1aa4 --- /dev/null +++ b/test/e2e/fixture-paths.ts @@ -0,0 +1,12 @@ +import * as path from 'node:path' + +export const e2eRoot = import.meta.dirname + +export const repositoryRoot = path.resolve(e2eRoot, '..', '..') + +export type FixtureProject = + | 'closed-file-project-fixture' + | 'default-tags-project-fixture' + | 'emmet-disabled-project-fixture' + | 'plugin-missing-project-fixture' + | 'styled-project-fixture' diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 0000000..53354f9 --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "e2e", + "private": true, + "dependencies": { + "@styled/typescript-styled-plugin": "workspace:*", + "typescript": "~6.0.3" + }, + "devDependencies": { + "typescript-5.5": "npm:typescript@5.5.4", + "typescript-5.9": "npm:typescript@5.9.3", + "typescript-current": "npm:typescript@6.0.3", + "typescript-legacy": "npm:typescript@4.9.5", + "typescript-minimum": "npm:typescript@5.0.4" + } +} diff --git a/test/e2e/plugin-missing-project-fixture/main.ts b/test/e2e/plugin-missing-project-fixture/main.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/plugin-missing-project-fixture/main.ts @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/plugin-missing-project-fixture/tsconfig.json b/test/e2e/plugin-missing-project-fixture/tsconfig.json new file mode 100644 index 0000000..d5365e6 --- /dev/null +++ b/test/e2e/plugin-missing-project-fixture/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "plugins": [{ "name": "missing-tsserver-plugin" }], + "target": "ES2021" + } +} diff --git a/test/e2e/scenarios/code-fixes.test.ts b/test/e2e/scenarios/code-fixes.test.ts new file mode 100644 index 0000000..d6bd5c9 --- /dev/null +++ b/test/e2e/scenarios/code-fixes.test.ts @@ -0,0 +1,159 @@ +import type { server as tsserver } from 'typescript' +import { assert, describe, it } from 'vitest' + +import { + bodyOf, + pluginDiagnosticCode, + unicodeLineBreaks, + useSharedServer, +} from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +const renameToBorder = "Rename to 'border'" + +describe('Code fixes', () => { + const server = useSharedServer() + + /** The code fixes tsserver offers for `span` of `file`, answering `errorCodes`. */ + async function fixesAt( + file: string, + { end, start }: tsserver.protocol.TextSpan, + errorCodes = [pluginDiagnosticCode], + ): Promise { + const response = await server().request('getCodeFixes', { + endLine: end.line, + endOffset: end.offset, + errorCodes, + file, + startLine: start.line, + startOffset: start.offset, + }) + return bodyOf(response) + } + + const at = (source: ReturnType, caret: string) => ({ + end: source.at(caret), + start: source.at(caret), + }) + + const descriptions = (fixes: readonly tsserver.protocol.CodeAction[]) => + fixes.map(({ description }) => description) + + it('should return a code fix for a misspelled property', async () => { + const source = mark('const misspelled = css`b⟨caret⟩oarder: 1px solid black;`') + const file = server().open(source.text) + + /** vscode-css-languageservice ranks these three by similarity to "boarder". */ + assert.deepEqual(descriptions(await fixesAt(file, at(source, 'caret'))), [ + renameToBorder, + "Rename to 'border-top'", + "Rename to 'border-left'", + ]) + }) + + it('should return a code fix when the cursor is at the diagnostic start', async () => { + const source = mark('const atStart = css`⟨caret⟩boarder: 1px solid black;`') + const file = server().open(source.text) + + assert.include(descriptions(await fixesAt(file, at(source, 'caret'))), renameToBorder) + }) + + it('should not return CSS code fixes for unrelated diagnostic codes', async () => { + const source = mark('const unrelated = css`b⟨caret⟩oarder: 1px solid black;`') + const file = server().open(source.text) + + assert.deepEqual(await fixesAt(file, at(source, 'caret'), [2304]), []) + /** + * Positive control in the same session: the same position with this plugin's own code returns + * the fix, so the empty result above comes from the errorCodes filter. + */ + assert.include(descriptions(await fixesAt(file, at(source, 'caret'))), renameToBorder) + }) + + it('should not return code fixes for correctly spelled properties', async () => { + const spelled = mark('const spelled = css`b⟨caret⟩order: 1px solid black;`') + const misspelled = mark('const misspelledJs = css`b⟨caret⟩oarder: 1px solid black;`') + const spelledFile = server().open(spelled.text) + const misspelledFile = server().open(misspelled.text, { + fileName: 'script-kind-js.js', + scriptKindName: 'JS', + }) + + assert.deepEqual(await fixesAt(spelledFile, at(spelled, 'caret')), []) + /** Positive control in the same session: a misspelled property in a second open file. */ + assert.include( + descriptions(await fixesAt(misspelledFile, at(misspelled, 'caret'))), + renameToBorder, + ) + }) + + it('should not return a code fix whose edit would split a JavaScript escape', async () => { + /** `\x62` is "b": the property reads `boarder` at runtime, but a rename would edit `2oarder`. */ + const escaped = mark('const escaped = css`⟨name⟩\\x62oarder⟨/name⟩: 1px solid black;`') + const plain = mark('const plain = css`⟨name⟩boarder⟨/name⟩: 1px solid black;`') + const escapedFile = server().open(escaped.text, { + fileName: 'script-kind-js.js', + scriptKindName: 'JS', + }) + const plainFile = server().open(plain.text) + + assert.deepEqual(await fixesAt(escapedFile, escaped.range('name')), []) + /** Positive control in the same session: the same property written without the escape. */ + assert.include(descriptions(await fixesAt(plainFile, plain.range('name'))), renameToBorder) + }) + + it('should map a code fix after a multiline interpolation to the source file', async () => { + const source = mark( + [ + 'function css(strings: TemplateStringsArray, ...values: unknown[]) { return ""; }', + 'const q = css`', + ' color: ${', + ' "red"', + ' };', + ' ⟨name⟩boarder⟨/name⟩: 1px solid black;', + '`', + ].join('\n'), + ) + const file = server().open(source.text) + + const fix = (await fixesAt(file, source.range('name'))).find( + ({ description }) => description === renameToBorder, + ) + + assert.deepEqual(fix?.changes, [ + { fileName: file, textChanges: [{ newText: 'border', ...source.range('name') }] }, + ]) + }) + + it.each(unicodeLineBreaks)( + 'should map a code fix after the Unicode %s', + async (_description, separator) => { + const source = mark( + `const q = css\`color: red;${separator}⟨name⟩boarder⟨/name⟩: 1px solid black;\``, + ) + const file = server().open(source.text) + + const fix = (await fixesAt(file, source.range('name'))).find( + ({ description }) => description === renameToBorder, + ) + + assert.deepEqual(fix?.changes, [ + { fileName: file, textChanges: [{ newText: 'border', ...source.range('name') }] }, + ]) + }, + ) + + it('should only return a spelling code fix when the range includes the misspelled property', async () => { + /** + * A zero-width request range is a point. A point exactly at the diagnostic's exclusive end is + * after the property, not at its last character. + */ + const source = mark('const q = css⟨before⟩`boa⟨inside⟩rder⟨after⟩: 1px solid black;`') + const file = server().open(source.text) + + assert.deepEqual(await fixesAt(file, at(source, 'before')), []) + /** Positive control in the same file: a point inside the property returns the fix. */ + assert.include(descriptions(await fixesAt(file, at(source, 'inside'))), renameToBorder) + assert.deepEqual(await fixesAt(file, at(source, 'after')), []) + }) +}) diff --git a/test/e2e/scenarios/completion-entry-details.test.ts b/test/e2e/scenarios/completion-entry-details.test.ts new file mode 100644 index 0000000..080adba --- /dev/null +++ b/test/e2e/scenarios/completion-entry-details.test.ts @@ -0,0 +1,25 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, startServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +describe('CompletionEntryDetails', () => { + it('should return details for color completion', async (context) => { + const server = startServer(context) + const source = mark('const q = css`color:⟨caret⟩`') + const file = server.open(source.text) + + const details = bodyOf( + await server.request('completionEntryDetails', { + file, + ...source.at('caret'), + entryNames: ['blue'], + }), + ) + + assert.deepEqual( + details.map(({ documentation, name }) => ({ documentation, name })), + [{ documentation: [{ kind: 'text', text: '#0000ff' }], name: 'blue' }], + ) + }) +}) diff --git a/test/e2e/scenarios/completions.test.ts b/test/e2e/scenarios/completions.test.ts new file mode 100644 index 0000000..3593c00 --- /dev/null +++ b/test/e2e/scenarios/completions.test.ts @@ -0,0 +1,295 @@ +import type { server as tsserver } from 'typescript' +import { assert, describe, it } from 'vitest' + +import { + bodyOf, + startServer, + unicodeLineBreaks, + useSharedServer, +} from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +describe('Completions', () => { + const server = useSharedServer() + + /** Opens `source` and returns the completions at its `⟨caret⟩`. */ + async function completionsAt(source: string): Promise { + const marked = mark(source) + const file = server().open(marked.text) + return bodyOf(await server().request('completions', { file, ...marked.at('caret') })) + } + + async function namesAt(source: string): Promise { + return (await completionsAt(source)).map(({ name }) => name) + } + + it('should return property value completions for single line string', async () => { + const names = await namesAt('const single = css`color:⟨caret⟩`') + + assert.includeMembers(names, ['aliceblue', 'rgba']) + }) + + it('should not return SCSS functions in property value completions', async () => { + const names = await namesAt('const scss = css`color:⟨caret⟩`') + + /** Positive control: the ordinary colors prove the plugin answered and filtered the list. */ + assert.include(names, 'aliceblue') + assert.notInclude(names, 'darken') + }) + + it('should return property value completions for multiline string', async () => { + const names = await namesAt(['const q = css`', 'color:⟨caret⟩', '`'].join('\n')) + + assert.include(names, 'aliceblue') + }) + + it.each(unicodeLineBreaks)( + 'should return completions after the Unicode %s', + async (_description, separator) => { + const names = await namesAt(`const q = css\`color: red;${separator}color:⟨caret⟩\``) + + assert.include(names, 'aliceblue') + }, + ) + + it('should return property value completions for nested selector', async () => { + const names = await namesAt('const q = css`position: relative; &:hover { color:⟨caret⟩ }`') + + assert.include(names, 'aliceblue') + }) + + it('should not return css completions on tag', async () => { + const source = mark(['css.⟨tag⟩``', 'const q = css`color:⟨value⟩`'].join('\n')) + const file = server().open(source.text) + + const onTag = await server().request('completions', { file, ...source.at('tag') }) + const inTemplate = await server().request('completions', { file, ...source.at('value') }) + + assert.deepInclude(onTag, { message: 'No content available.', success: false }) + /** Positive control in the same file: the template after the tag still gets CSS values. */ + assert.include( + bodyOf(inTemplate).map(({ name }) => name), + 'aliceblue', + ) + }) + + it('should return completions when placeholder is used as property', async () => { + const names = await namesAt('css`color:⟨caret⟩ ; boarder: 1px solid ${"red"};`') + + assert.include(names, 'aliceblue') + }) + + it('should return completions after a placeholder is used as a property', async () => { + const names = await namesAt('css`border: 1px solid ${"red"}; color:⟨caret⟩`') + + assert.include(names, 'aliceblue') + }) + + it('should recover from a completions request that throws inside the CSS language service and keep serving later requests', async () => { + /** + * The first caret sits at the start of the property name the substitution generates for a + * placeholder used as a property name followed by a value placeholder: completions there throw + * inside vscode-css-languageservice (declaration.getProperty is not a function). The plugin + * must answer with nothing instead of letting the exception reach tsserver, and the session + * must keep answering. + */ + const source = mark( + [ + 'const first = css`', + " ⟨throws⟩${'prop'}: ${'value'};", + '`', + 'const second = css`color:⟨later⟩`', + ].join('\n'), + ) + const file = server().open(source.text) + + const throwing = await server().request('completions', { file, ...source.at('throws') }) + const later = await server().request('completions', { file, ...source.at('later') }) + + assert.deepEqual(bodyOf(throwing), []) + assert.include( + bodyOf(later).map(({ name }) => name), + 'aliceblue', + ) + }) + + it('should return completions between placeholders used as properties', async () => { + const names = await namesAt('css`boarder: 1px solid ${"red"}; color:⟨caret⟩ ; margin: ${20}; `') + + assert.include(names, 'aliceblue') + }) + + it('should return completions on tagged template string with placeholder using dotted tag', async () => { + const names = await namesAt('css.x`color:⟨caret⟩ ; boarder: 1px solid ${"red"};`') + + assert.include(names, 'aliceblue') + }) + + it('should return js completions inside placeholder', async () => { + const names = await namesAt('const abc = 123; css`color: ${⟨caret⟩};`') + + assert.include(names, 'abc') + }) + + it('should return js completions at end of placeholder', async () => { + const names = await namesAt('css`color: ${"red".⟨caret⟩};`') + + assert.include(names, 'substr') + }) + + it('should return styled completions inside of nested placeholder', async () => { + const names = await namesAt('styled`background: red; ${(() => css`color:⟨caret⟩`)()}`;') + + assert.include(names, 'aliceblue') + }) + + it('should handle multiline value placeholder correctly', async () => { + const names = await namesAt(['css`margin: ${', '0', '}; color:⟨caret⟩ `'].join('\n')) + + assert.include(names, 'aliceblue') + }) + + it('should handle multiline rule placeholder correctly', async () => { + const names = await namesAt( + ['css`', '${', 'css`margin: 0;`', '}', 'color: ⟨caret⟩`'].join('\n'), + ) + + assert.include(names, 'aliceblue') + }) + + it('should return completions when placeholder is used as a selector', async () => { + const names = await namesAt( + ['css`${"button"} {', ' color: ⟨caret⟩;', '}', 'color: ;', '`'].join('\n'), + ) + + assert.include(names, 'aliceblue') + }) + + it('should return completions inside a nested selector', async () => { + const names = await namesAt( + ['css`', ' color: red;', ' &:hover {', ' color:⟨caret⟩ ', ' }', '`'].join( + '\n', + ), + ) + + assert.include(names, 'aliceblue') + }) + + it('should support tag that is a function call', async () => { + const names = await namesAt('const q = css("bla")`color:⟨caret⟩`') + + assert.includeMembers(names, ['aliceblue', 'rgba']) + }) + + it('should support tag that is a templated function call', async () => { + const names = await namesAt("const q = css('bla')`color:⟨caret⟩`") + + assert.includeMembers(names, ['aliceblue', 'rgba']) + }) + + it('should offer nested at-rules with documentation for an at-keyword at statement position', async () => { + const source = mark( + ['const q = styled.div`', ' color: red;', ' ⟨word⟩@me⟨/word⟩', '`'].join('\n'), + ) + const file = server().open(source.text) + const caret = source.range('word').end + + const completions = bodyOf(await server().request('completions', { file, ...caret })) + const details = bodyOf( + await server().request('completionEntryDetails', { file, ...caret, entryNames: ['@media'] }), + ) + + const atRules = completions + .filter((item) => /^@[a-z-]+$/.test(item.name)) + .map(({ insertText, isSnippet, kind, name, replacementSpan }) => ({ + insertText, + isSnippet, + kind, + name, + replacementSpan, + })) + .sort((left, right) => left.name.localeCompare(right.name)) + /** `unknown`: the protocol types `kind` as an enum this file does not load at runtime. */ + assert.deepEqual( + atRules, + [ + '@container', + '@counter-style', + '@font-face', + '@font-palette-values', + '@keyframes', + '@layer', + '@media', + '@page', + '@property', + '@scope', + '@starting-style', + '@supports', + ].map((name) => ({ + insertText: undefined, + isSnippet: undefined, + kind: 'keyword', + name, + replacementSpan: source.range('word'), + })), + ) + assert.deepEqual( + details.map(({ name }) => name), + ['@media'], + ) + assert.match( + details.flatMap(({ documentation = [] }) => documentation.map(({ text }) => text)).join(''), + /media type/i, + ) + }) + + it('should mark color completions with "color" kindModifier', async () => { + const completions = await completionsAt('const kind = css`color:⟨caret⟩`') + + assert.strictEqual( + completions.find((item) => item.name === 'aliceblue')?.kindModifiers, + 'color', + ) + }) + + it('should offer property completions in an empty template', async () => { + const names = await namesAt('const q = styled.div`⟨caret⟩`') + + assert.include(names, 'display') + }) + + it('should get completions inside keyframes blocks', async () => { + const completions = await completionsAt('const q = keyframes`0% {color:⟨caret⟩`') + + assert.strictEqual( + completions.find((item) => item.name === 'aliceblue')?.kindModifiers, + 'color', + ) + }) + + it('should never return snippet insertion text, even after the client configures includeCompletionsWithSnippetText like VS Code always does', async (context) => { + /** + * VS Code's TypeScript extension always sends includeCompletionsWithSnippetText as true + * (extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts), + * so the plugin cannot use that preference to decide whether to emit snippet tab stops. The + * preference is session-wide, so this test runs its own server. + */ + const ownServer = startServer(context) + const source = mark('const q = css`bor⟨caret⟩`') + const file = ownServer.open(source.text) + + const before = await ownServer.request('completions', { file, ...source.at('caret') }) + const configured = await ownServer.request('configure', { + preferences: { includeCompletionsWithSnippetText: true }, + }) + const after = await ownServer.request('completions', { file, ...source.at('caret') }) + + assert.isTrue(configured.success) + for (const response of [before, after]) { + const border = bodyOf(response).find((item) => item.name === 'border') + assert.isDefined(border) + assert.isUndefined(border.isSnippet) + assert.isUndefined(border.insertText) + } + }) +}) diff --git a/test/e2e/scenarios/edit-outside-template.test.ts b/test/e2e/scenarios/edit-outside-template.test.ts new file mode 100644 index 0000000..2d5ab7c --- /dev/null +++ b/test/e2e/scenarios/edit-outside-template.test.ts @@ -0,0 +1,117 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, pluginDiagnostics, startServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' +import type { TSServer } from '../tsserver-fixture/server' + +/** + * Sends a "change" that inserts `insertion` at the `⟨marker⟩` of `source`, and returns the marked + * source after the edit: the same text with `insertion` placed after that marker, so every other + * marker shifts exactly as the edit shifts the text it marks. + */ +function insertAt( + server: TSServer, + { + file, + insertion, + marker, + source, + }: { file: string; insertion: string; marker: string; source: string }, +) { + const { line, offset } = mark(source).at(marker) + server.notify('change', { + endLine: line, + endOffset: offset, + file, + insertString: insertion, + line, + offset, + }) + return mark(source.replace(`⟨${marker}⟩`, `⟨${marker}⟩${insertion}`)) +} + +describe.concurrent('Editing outside a template', () => { + it('should map diagnostics, hover, and completions to the shifted position after a line is inserted above the template', async (context) => { + const server = startServer(context) + const source = [ + '⟨top⟩function css(strings: TemplateStringsArray, ...values: unknown[]) { return ""; }', + 'const q = css`', + ' ⟨hover⟩color: red⟨/hover⟩;', + ' ⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;', + ' ⟨backgr⟩backgr⟨/backgr⟩', + '`', + ].join('\n') + const file = server.open(mark(source).text) + + /** + * Builds and caches the template's virtual document before the edit: the defect this test + * targets only surfaces on a reused document, never on one built fresh for the first request. + */ + await server.request('semanticDiagnosticsSync', { file }) + const after = insertAt(server, { + file, + insertion: '// a comment line inserted above the template\n', + marker: 'top', + source, + }) + + const diagnostics = pluginDiagnostics(await server.request('semanticDiagnosticsSync', { file })) + const hover = bodyOf(await server.request('quickinfo', { file, ...after.at('hover') })) + const completions = bodyOf( + await server.request('completions', { file, ...after.range('backgr').end }), + ) + + /** + * The trailing "backgr" line (no colon or terminator) also reports its own end-of-template + * diagnostics, unrelated to the mapping under test, so only the "boarder" one is asserted. + */ + const boarder = diagnostics.find(({ text }) => text === "Unknown property: 'boarder'") + assert.deepEqual({ end: boarder?.end, start: boarder?.start }, after.range('boarder')) + assert.deepEqual({ end: hover.end, start: hover.start }, after.range('hover')) + assert.deepEqual( + completions.find(({ name }) => name === 'background-color')?.replacementSpan, + after.range('backgr'), + ) + }) + + it('should map hover and completions on the second template to the shifted position after a line is inserted between two templates', async (context) => { + /** + * The cache holds one document at a time, keyed by file name, raw text, and wrapper. A whole + * file sweep (diagnostics, folding) visits every template and rebuilds each one, so it cannot + * reach the reuse path. Hover and completions resolve one template by cursor position, so the + * same request on the same template before and after an edit between the templates does. + */ + const server = startServer(context) + const source = [ + 'function css(strings: TemplateStringsArray, ...values: unknown[]) { return ""; }', + 'const a = css`margin: 0;`', + '⟨between⟩const b = css`', + ' ⟨hover⟩color: red⟨/hover⟩;', + ' ⟨backgr⟩backgr⟨/backgr⟩', + '`', + ].join('\n') + const before = mark(source) + const file = server.open(before.text) + + /** Caches template b's document with a single-template request. */ + const hoverBefore = bodyOf(await server.request('quickinfo', { file, ...before.at('hover') })) + const after = insertAt(server, { + file, + insertion: '// a comment line inserted between the two templates\n', + marker: 'between', + source, + }) + + const hover = bodyOf(await server.request('quickinfo', { file, ...after.at('hover') })) + const completions = bodyOf( + await server.request('completions', { file, ...after.range('backgr').end }), + ) + + assert.deepEqual({ end: hoverBefore.end, start: hoverBefore.start }, before.range('hover')) + assert.deepEqual({ end: hover.end, start: hover.start }, after.range('hover')) + assert.deepEqual( + completions.find(({ name }) => name === 'background-color')?.replacementSpan, + after.range('backgr'), + ) + }) +}) diff --git a/test/e2e/scenarios/emmet-completions.test.ts b/test/e2e/scenarios/emmet-completions.test.ts new file mode 100644 index 0000000..0bb206c --- /dev/null +++ b/test/e2e/scenarios/emmet-completions.test.ts @@ -0,0 +1,158 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, startServer, useSharedServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' +import type { TSServer } from '../tsserver-fixture/server' + +const marginExpansion = 'margin: 10px 20px;' + +/** Opens `source` and returns a reader of the completion names at any of its carets. */ +function openMarked(server: TSServer, source: string): (caret: string) => Promise { + const marked = mark(source) + const file = server.open(marked.text) + return async (caret) => + bodyOf(await server.request('completions', { file, ...marked.at(caret) })).map( + ({ name }) => name, + ) +} + +describe('Emmet Completions', () => { + const server = useSharedServer() + + const namesAtCaret = (source: string) => openMarked(server(), source)('caret') + + it('should not return Emmet property completions when disabled', async (context) => { + const disabledServer = startServer(context, { project: 'emmet-disabled-project-fixture' }) + const namesAt = openMarked(disabledServer, 'const q = css`color:⟨css⟩ ; m10-20⟨emmet⟩`') + + /** Positive control in the same session: CSS completions still arrive. */ + assert.include(await namesAt('css'), 'aliceblue') + assert.notInclude(await namesAt('emmet'), marginExpansion) + }) + + it('should return Emmet property completions for a single-line string', async () => { + assert.include(await namesAtCaret('const single = css`m10-20⟨caret⟩`'), marginExpansion) + }) + + it('should return Emmet property completions for a multiline string', async () => { + assert.include( + await namesAtCaret(['const q = css`', 'm10-20⟨caret⟩', '`'].join('\n')), + marginExpansion, + ) + }) + + it('should return Emmet property completions for a nested selector', async () => { + assert.include( + await namesAtCaret('const q = css`position: relative; &:hover { m10-20⟨caret⟩ }`'), + marginExpansion, + ) + }) + + it('should return emmet completions when placeholder is used as property', async () => { + assert.include( + await namesAtCaret('css`m10-20⟨caret⟩ ; boarder: 1px solid ${"red"};`'), + marginExpansion, + ) + }) + + it('should return Emmet completions after a placeholder is used as a property', async () => { + assert.include( + await namesAtCaret('css`border: 1px solid ${"red"}; m10-20⟨caret⟩`'), + marginExpansion, + ) + }) + + it('should return Emmet completions between placeholders used as properties', async () => { + assert.include( + await namesAtCaret('css`boarder: 1px solid ${"red"}; color: #12⟨caret⟩; margin: ${20}; `'), + '#121212', + ) + }) + + it('should return emmet completions on tagged template string with placeholder using dotted tag', async () => { + assert.include( + await namesAtCaret('css.x`color: #12⟨caret⟩ ; boarder: 1px solid ${"red"};`'), + '#121212', + ) + }) + + it('should return styled emmet completions inside of nested placeholder', async () => { + assert.include( + await namesAtCaret('styled`background: red; ${(() => css`color: #12⟨caret⟩`)()}`;'), + '#121212', + ) + }) + + it('should handle emmet completions in multiline value placeholder correctly', async () => { + assert.include( + await namesAtCaret(['css`margin: ${', '0', '}; color: #12⟨caret⟩`'].join('\n')), + '#121212', + ) + }) + + it('should handle emmet completions in multiline rule placeholder correctly', async () => { + assert.include( + await namesAtCaret(['css`', '${', 'css`margin: 0;`', '}', 'color: #12⟨caret⟩`'].join('\n')), + '#121212', + ) + }) + + it('should return Emmet completions inside a nested selector', async () => { + assert.include( + await namesAtCaret( + [ + 'css`', + ' color: red;', + ' &:hover {', + ' color: #12⟨caret⟩ ', + ' }', + '`', + ].join('\n'), + ), + '#121212', + ) + }) + + it('should offer no Emmet declaration in value position, a string, or a comment, and keep it at statement position', async () => { + const namesAt = openMarked( + server(), + 'const q = css`display: fl⟨value⟩; content: "w10⟨string⟩"; /* p10⟨comment⟩ */ m10-20⟨statement⟩`', + ) + + const valueNames = await namesAt('value') + assert.include(valueNames, 'flex') + assert.notInclude(valueNames, 'float: left;') + assert.deepEqual(await namesAt('string'), []) + assert.deepEqual(await namesAt('comment'), []) + /** Positive control in the same file: statement position still expands. */ + assert.include(await namesAt('statement'), marginExpansion) + }) + + it('should offer no Emmet declaration in a selector, an at-rule prelude, or at the end of an unterminated string', async () => { + const namesAt = openMarked( + server(), + 'const q = css`&:hover m10⟨selector⟩ { color: red; } @media m10⟨prelude⟩ { color: red; } m10-20⟨statement⟩`; const r = css`content: "a; @⟨string⟩`', + ) + + const selectorNames = await namesAt('selector') + assert.include(selectorNames, ':active') + assert.notInclude(selectorNames, 'margin: 10px;') + assert.notInclude(await namesAt('prelude'), 'margin: 10px;') + /** Positive control in the same file: statement position still expands. */ + assert.include(await namesAt('statement'), marginExpansion) + assert.deepEqual(await namesAt('string'), []) + }) + + it('should mark emmet completions as isIncomplete', async () => { + const source = mark('const incomplete = css`m10-20⟨caret⟩`') + const file = server().open(source.text) + + const response = await server().request('completions', { file, ...source.at('caret') }) + + assert.include( + bodyOf(response).map(({ name }) => name), + marginExpansion, + ) + assert.deepEqual(response.metadata, { isIncomplete: true }) + }) +}) diff --git a/test/e2e/scenarios/errors.test.ts b/test/e2e/scenarios/errors.test.ts new file mode 100644 index 0000000..48a3162 --- /dev/null +++ b/test/e2e/scenarios/errors.test.ts @@ -0,0 +1,841 @@ +import { readFileSync } from 'node:fs' + +import { assert, describe, it } from 'vitest' + +import { + diagnosticsOf, + pluginDiagnostics, + spansAndText, + startServer, + unicodeLineBreaks, + useSharedServer, +} from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +const cssFunction = + 'function css(strings: TemplateStringsArray, ...values: unknown[]) { return ""; }' + +/** A template whose misspelled property must be reported: the positive control for "no error". */ +const controlLines = ['const Control = styled.div`', ' ⟨control⟩colr⟨/control⟩: red;', '`'] + +describe('Errors', () => { + const server = useSharedServer() + + /** Opens the marked `source` and returns it with the plugin's diagnostics for it. */ + async function diagnose(source: string) { + const marked = mark(source) + const file = server().open(marked.text) + const response = await server().request('semanticDiagnosticsSync', { file }) + return { diagnostics: pluginDiagnostics(response), marked, response } + } + + const unknownProperty = (marked: ReturnType, name: string, rangeName = name) => ({ + ...marked.range(rangeName), + text: `Unknown property: '${name}'`, + }) + + it('should return no semantic diagnostics for valid TypeScript and CSS', async () => { + const { marked, response } = await diagnose( + [ + 'declare const styled: { div(strings: TemplateStringsArray): string }', + 'function css(x: TemplateStringsArray) { return x; }; const q = css`color: red;`', + ...controlLines, + ].join('\n'), + ) + + /** Every diagnostic, TypeScript's included; the control proves the plugin answered. */ + assert.deepEqual(spansAndText(diagnosticsOf(response)), [ + unknownProperty(marked, 'colr', 'control'), + ]) + }) + + it('should return error for unknown property', async () => { + const { diagnostics, marked } = await diagnose( + 'function css(x: TemplateStringsArray) { return x; }; const q = css`⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;`', + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'boarder')]) + }) + + it.each([ + ['an unclosed placeholder after a property', 'const q = css`color: ${'], + ['an unclosed placeholder at the template start', 'const q = css`${value'], + ])('should not return CSS errors for %s', async (_description, incomplete) => { + /** The control comes first: nothing after an unclosed placeholder parses as its own template. */ + const { diagnostics, marked } = await diagnose( + ['declare const styled: any, value: any', cssFunction, ...controlLines, incomplete].join( + '\n', + ), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr', 'control')]) + }) + + it('should return errors when error occurs in last position', async () => { + const { diagnostics, marked } = await diagnose( + `${cssFunction}; const q = css\`⟨semicolon⟩;⟨/semicolon⟩⟨end⟩\``, + ) + + /** + * The stray ";" leaves the wrapper's opening rule unclosed, reported on the ";" itself. The + * parser's cascading "at-rule or selector expected" is always kept; with no "}" in the + * template to re-anchor to, it stays at the template end. + */ + assert.deepEqual(spansAndText(diagnostics), [ + { ...marked.range('semicolon'), text: '} expected' }, + { end: marked.at('end'), start: marked.at('end'), text: 'at-rule or selector expected' }, + ]) + }) + + it('should report the cascading end-of-template diagnostic from a stray closing brace alongside the real one', async () => { + const { diagnostics, marked } = await diagnose( + [ + cssFunction, + 'const q = css`', + ' color: red;', + ' ⟨stray⟩}⟨/stray⟩', + ' margin⟨colon⟩:⟨/colon⟩ 0;', + '`', + ].join('\n'), + ) + + /** + * The stray "}" closes the wrapper rule early, so "margin: 0;" parses outside any rule and + * reports "{ expected". The cascading "at-rule or selector expected" is kept, re-anchored to + * the stray "}" that caused it. + */ + assert.deepEqual(spansAndText(diagnostics), [ + { ...marked.range('stray'), text: 'at-rule or selector expected' }, + { ...marked.range('colon'), text: '{ expected' }, + ]) + }) + + it('should report a stray "}" as an error-level diagnostic even when an unrelated lint warning starts after it', async () => { + /** + * Everything after the "}" parses (".a { colr: red; }" is valid apart from the misspelled + * property), so the "colr" warning is not the cascade the brace caused and must not stand in + * for it. + */ + const { diagnostics, marked } = await diagnose( + `${cssFunction}; const q = css\`color: red; ⟨stray⟩}⟨/stray⟩ .a { ⟨colr⟩colr⟨/colr⟩: red; }\``, + ) + + assert.deepEqual(spansAndText(diagnostics), [ + { ...marked.range('stray'), text: 'at-rule or selector expected' }, + unknownProperty(marked, 'colr'), + ]) + assert.strictEqual( + diagnostics.find(({ text }) => text === 'at-rule or selector expected')?.category, + 'error', + ) + }) + + it.each([ + ['a trailing stray "}" with nothing after it', 'color: red; ⟨stray⟩}⟨/stray⟩'], + ['a bare "}" with no other content', '⟨stray⟩}⟨/stray⟩'], + [ + 'a stray "}" followed by a rule that parses cleanly on its own', + 'color: red; ⟨stray⟩}⟨/stray⟩ a { color: blue; }', + ], + ['a matched rule followed by an extra, unmatched "}"', 'a { color: red; }⟨stray⟩}⟨/stray⟩'], + ])( + /** + * A stray "}" that is a template's only error is reported, anchored to the brace, rather than + * dropped as a duplicate of the cascading diagnostic at the template end. + */ + 'should report a stray "}" as a real diagnostic when it is the template\'s only error: %s', + async (_description, templateText) => { + const { diagnostics, marked } = await diagnose( + `${cssFunction}; const q = css\`${templateText}\``, + ) + + assert.deepEqual(spansAndText(diagnostics), [ + { ...marked.range('stray'), text: 'at-rule or selector expected' }, + ]) + }, + ) + + it('should report a stray "}" alongside an unrelated diagnostic that starts before it', async () => { + const { diagnostics, marked } = await diagnose( + `${cssFunction}; const q = css\`⟨colr⟩colr⟨/colr⟩: red; ⟨stray⟩}⟨/stray⟩\``, + ) + + assert.deepEqual(spansAndText(diagnostics), [ + unknownProperty(marked, 'colr'), + { ...marked.range('stray'), text: 'at-rule or selector expected' }, + ]) + }) + + it('should return error for multiline unknown property #20', async () => { + const { diagnostics, marked } = await diagnose( + [ + 'function css(x: TemplateStringsArray) { return x; };', + 'const q = css`', + '⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;', + '`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'boarder')]) + }) + + it.each(unicodeLineBreaks)( + 'should map diagnostics after the Unicode %s', + async (_description, separator) => { + const { diagnostics, marked } = await diagnose( + `const q = css\`color: red;${separator}⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;\``, + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'boarder')]) + }, + ) + + it.each(unicodeLineBreaks)( + 'should map diagnostics after an interpolation containing the Unicode %s', + async (_description, separator) => { + const { diagnostics, marked } = await diagnose( + `const value = 'red'; const q = css\`color: \${${separator}value${separator}};${separator}⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;\``, + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'boarder')]) + }, + ) + + it.each(unicodeLineBreaks)( + 'should not mistake a Unicode %s inside a quoted string value for a declaration separator', + async (_description, separator) => { + /** TypeScript counts the separator as a line break even inside the CSS string. */ + const { diagnostics, marked } = await diagnose( + `const q = css\`content: "a${separator}b"; ⟨colr⟩colr⟨/colr⟩: red;\``, + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr')]) + }, + ) + + it('should map diagnostics after a multiline interpolation to the source file', async () => { + const { diagnostics, marked } = await diagnose( + [ + `${cssFunction};`, + 'const q = css`', + ' color: ${', + ' "red"', + ' };', + ' ⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;', + '`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'boarder')]) + }) + + it('should map diagnostics after dynamic declaration names and values (#25)', async () => { + const { diagnostics, marked } = await diagnose( + [ + 'declare const styled: { div(strings: TemplateStringsArray, ...values: unknown[]): string };', + 'declare const varName: string;', + 'declare const value: string;', + 'const StyledComponent = styled.div`', + ' ${varName}: ${value};', + ' ⟨boarder⟩boarder⟨/boarder⟩: 1px solid black;', + '`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'boarder')]) + }) + + it('should include error for unknown property in selector', async () => { + const { diagnostics, marked } = await diagnose( + [ + 'const css = {} as { span: (strings: TemplateStringsArray, ...values: unknown[]) => string };', + 'const ListNoteTitle = css.span`', + ' width: 100%;', + ' &:hover {', + ' ⟨noSuch⟩noSuch⟨/noSuch⟩: red;', + ' }', + '`;', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'noSuch')]) + }) + + it.each([ + ['used as a selector', [' ${', ' B', ' }:hover & {', ' color: red;', ' }']], + ['in a hex color value', [' color: #${', ' B', ' };']], + ['used as a mixin', [' ${', ' B', ' };']], + ])( + 'should map a diagnostic after a multi-line placeholder %s', + async (_description, placeholderLines) => { + const { diagnostics, marked } = await diagnose( + [ + 'const styled = {} as { div: (strings: TemplateStringsArray, ...values: unknown[]) => string }; const B: unknown = 1; const q = styled.div`', + ...placeholderLines, + ' ⟨colr⟩colr⟨/colr⟩: blue;', + '`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr')]) + }, + ) + + it.each([ + ['a property name with no value or terminator', '\n margin: 0;\n color\n'], + ['a property name with a colon but no value', '\n color:\n'], + ['an unclosed rule', '\n a {\n'], + ['an unclosed nested at-rule', '\n @media screen {\n a { color: red; }\n'], + ['a bare property name on the template’s only line', 'margin: 0; color'], + ['an unclosed rule on the template’s only line', 'a {'], + ['a truncated declaration after a valid one', '\n color: red;\n b\n'], + ])( + 'should report a diagnostic at the template end for %s instead of dropping it', + async (_description, body) => { + const { diagnostics, marked } = await diagnose( + `${cssFunction}; const q = css\`${body}⟨end⟩\``, + ) + const templateEnd = marked.at('end') + + assert.deepInclude( + diagnostics.map(({ end, start }) => ({ end, start })), + { end: templateEnd, start: templateEnd }, + ) + for (const { start } of diagnostics) { + /** Never past the template end: the wrapper's synthetic closing "}" has no source text. */ + assert.isTrue( + start.line < templateEnd.line || + (start.line === templateEnd.line && start.offset <= templateEnd.offset), + `expected ${JSON.stringify(start)} to be at or before the template end ${JSON.stringify(templateEnd)}`, + ) + } + }, + ) + + it('should report an unknown property inside an @layer block', async () => { + const { diagnostics, marked } = await diagnose( + [ + 'declare const styled: any', + 'const Layered = styled.div`', + ' @layer utilities {', + ' ⟨colr⟩colr⟨/colr⟩: red;', + ' }', + '`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr')]) + }) + + /** + * Each row holds shapes that work at runtime and must report nothing, followed by a control + * template whose misspelled property must be the only plugin diagnostic, which also proves the + * plugin loaded and parsed past the row. Backslashes in the file text are JavaScript escapes, + * as a user writes them. + */ + it.each([ + ['an empty template', ['function css(x: TemplateStringsArray) { return x; }; const q = css``']], + [ + 'a nested ruleset', + [ + 'function css(x: TemplateStringsArray) { return x; }; const q = css`&:hover { border: 1px solid black; }`', + ], + ], + ['a placeholder in a property', [`${cssFunction}; const q = css\`color: \${"red"};\``]], + [ + 'a placeholder in a property of a multiline template', + [`${cssFunction}; const q = css\``, ' color: ${"red"};', '`'], + ], + [ + 'a placeholder at the start followed by semicolons (#22)', + [ + 'function css(...args: unknown[]){}', + 'css`${mixin}; color: blue;`', + 'css`', + ' ${mixin};', + ' color: blue;', + '`', + 'css`', + ' ${mixin} ;', + ' color: blue;', + '`', + 'css`', + ' ${mixin};;; ;; ;', + ' color: blue;', + '`', + ], + ], + [ + 'a placeholder used as a selector (#30)', + [`${cssFunction}; const q = css\`\${"button"} { color: red; }\``], + ], + [ + 'a placeholder used as a complex selector (#30)', + [ + `${cssFunction};`, + 'function fullWidth() { };', + 'const Button = {};', + 'const q = css`', + ' display: flex;', + ' ${fullWidth()};', + '', + ' ${Button} {', + ' width: 100%;', + '', + ' &:not(:first-child):not(:last-child) {', + ' margin-left: 0;', + ' margin-right: 0;', + ' border-radius: 0;', + ' }', + ' }', + '`', + ], + ], + [ + 'a placeholder used as a selector part (#39)', + [ + `${cssFunction}; const Content = "button"; const q = css\`& > \${Content} { margin-left: 1px; }\``, + ], + ], + [ + 'placeholders in multiple selectors (#39)', + [ + `${cssFunction}; const q = css\``, + " & > ${'content'} {", + ' color: 1px;', + ' }', + '', + " & > ${'styledNavBar'} {", + ' margin-left: ${1};', + ' }', + '`', + ], + ], + [ + 'a placeholder that spans multiple lines (#44)', + [ + 'const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; const q = css.a`', + ' color:', + " ${'transparent'};", + ' border-bottom: 1px;', + ' &:hover {', + ' color: inherit;', + ' text-decoration: none;', + ' }', + '`', + ], + ], + [ + 'a complicated style (#44)', + [ + 'const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; const q = css.a`', + ' display: flex;', + ' width: 6rem;', + ' height: 5rem;', + ' margin-right: -3px;', + ' border-right: 3px solid', + ' ${({ active, theme: { colors } }: { active: boolean; theme: { colors: { yellow: string } } }) =>', + " active ? colors.yellow : 'transparent'};", + ' border-bottom: 1px solid rgba(255, 255, 255, 0.5);', + ' font-weight: bold;', + ' font-size: 0.875rem;', + ' color: white;', + ' cursor: pointer;', + ' &:not([href]):not([tabindex]) {', + ' color: white;', + ' }', + ' &:hover {', + ' color: inherit;', + ' text-decoration: none;', + ' }', + '`', + ], + ], + [ + 'a placeholder value followed by a unit (#48)', + [`${cssFunction}; const width = 1; const q = css\``, ' width: ${width}%;', '`'], + ], + [ + 'a placeholder as the declaration name (#52)', + [`${cssFunction}; const q = css\``, " ${'width'}: 1px;", '`'], + ], + [ + 'dynamic declaration names and values (#25)', + [ + 'declare const varName: string;', + 'declare const value: string;', + 'const StyledComponent = styled.div`', + ' ${varName}: ${value};', + ' --${varName}: ${value};', + ' --theme-${varName}: ${value};', + ' --${varName}-${varName}: ${value};', + ' color: red; --${varName}: ${value};', + ' --føø-${varName}: ${value};', + ' :root { --${varName}: ${value}; }', + '`', + ], + ], + [ + 'a placeholder as part of a selector list (#59)', + [ + `${cssFunction}; const q = css\``, + " ${'a'}, ${'button'} {", + ' width: 1px;', + ' }', + '`', + ], + ], + [ + 'a placeholder used as an entire declaration inside a nested rule (#54)', + [ + `${cssFunction}; const q = css\``, + ' &.buu-foo {', + " ${'baseShape'};", + ' &.active {', + ' font-size: 2rem;', + ' }', + ' }', + '`', + ], + ], + [ + 'adjacent placeholders (#62)', + [ + 'const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; const margin1 = "3px"; const margin2 = "3px"; const q = css.a`', + ' margin: ${margin1} ${margin2};', + '`', + ], + ], + [ + 'a contextual selector (#71)', + [ + 'const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; const q = css.a`', + ' html.test & {', + ' display: none;', + ' }', + '`', + ], + ], + [ + 'a placeholder used in a contextual selector (#71)', + [ + "const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; let FlipContainer = 'button'; const q = css.a`", + ' position: relative;', + '', + ' ${FlipContainer}:hover & {', + ' transform: rotateY(180deg);', + ' }', + '`', + ], + ], + [ + 'a placeholder with an attribute selector before a contextual "&" (#67)', + [ + "const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; let OtherStyledElm = 'button'; const q = css.a`", + ' ${OtherStyledElm}:not([value=""]) + & {', + ' transform: rotateY(180deg);', + ' }', + '`', + ], + ], + [ + 'placeholders in a contextual selector list (#67)', + [ + "const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; let OtherStyledElm = 'button'; const q = css.a`", + ' ${OtherStyledElm} + &,', + ' ${OtherStyledElm}:not([value=""]) + & {', + ' transform: rotateY(180deg);', + ' }', + '`', + ], + ], + [ + 'a custom tag function (#21)', + [ + 'function css(): (value: unknown) => (strings: TemplateStringsArray) => string { return () => () => ""; }; const q = css<{}>()(window.blur)`', + ' display: none;', + '`', + ], + ], + [ + 'a sub-ruleset whose only content is a placeholder (#50)', + [ + 'const css = {} as { a: (strings: TemplateStringsArray, ...values: unknown[]) => string }; const q = css.a`', + ' :nth-of-type(1) {', + ' ${true ? "display: initial" : "display: hidden"}', + ' }', + '`', + ], + ], + [ + 'a placeholder before a contextual "&" after a value placeholder (#74)', + [ + 'const css = {} as { span: (strings: TemplateStringsArray, ...values: unknown[]) => string };', + "const ListNoteItem = 'bla';", + 'const ListNoteTitle = css.span`', + ' font-weight: bold;', + ' color: ${(props: { theme: { primaryColor: string } }) => props.theme.primaryColor};', + ' ${ListNoteItem}:hover & {', + ' text-decoration: underline;', + ' }', + '`;', + ], + ], + [ + 'a child selector (#75)', + [ + 'const css = {} as { span: (strings: TemplateStringsArray, ...values: unknown[]) => string };', + "const ListNoteItem = 'bla';", + 'const ListNoteTitle = css.span`', + ' width: 100%;', + ' > ${ListNoteItem}:hover {', + ' color: red;', + ' }', + '`;', + ], + ], + [ + 'newer properties (#95, #53)', + [ + 'const css = {} as { span: (strings: TemplateStringsArray, ...values: unknown[]) => string };', + 'const ListNoteTitle = css.span`', + ' scrollbar-width: 10px;', + ' scrollbar-color: red;', + ' scroll-snap-align: initial;', + '`;', + ], + ], + [ + 'object interpolations with nested templates', + [ + cssFunction, + 'const styles = {', + ' active: css`color: ${({ theme: { colors } }: { theme: { colors: { primary: string } } }) => colors.primary};`,', + '}', + 'const q = css`', + ' ${styles.active}', + ' &:hover {', + ' ${css`background: ${({ tone }: { tone: string }) => tone};`}', + ' }', + '`', + ], + ], + [ + 'styled-components v7 block-position placeholders, nested layers, and value fragments', + [ + 'declare const css: any, kf: any', + 'const SameLineMixin = styled.div`', + ' color: red; ${mixin}', + ' margin: 0;', + '`', + 'const InlineBlockMixin = styled.div`', + ' &:hover { ${mixin} }', + '`', + 'const TwoMixins = styled.div`', + ' ${a} ${b}', + ' margin: 0;', + '`', + 'const Layers = styled.div`', + ' @layer utilities { color: red; }', + ' &:hover {', + ' @layer framework.base { color: red; }', + ' }', + ' @layer {', + ' color: red;', + ' }', + '`', + 'const Border = css`1px solid red`', + 'const Animation = css`${kf} 1s linear`', + ], + ], + [ + 'placeholders joined to property names', + [ + 'const Sides = styled.div`', + ' padding-${side}: 4px;', + ' border-${side}-color: red;', + ' ${side}-top: 0;', + ' ${side}-width: 1px;', + ' margin: 0;', + '`', + ], + ], + [ + 'property name placeholders before a later "&" on the same line', + [ + 'const LaterRules = styled.div`', + ' padding-${side}: 0; &:hover { color: red; }', + ' ${side}-top: 0; &:focus { color: red; }', + ' border-${side}-color: red; &:active { color: red; }', + ' ${side}: 0; &:disabled { color: red; }', + '`', + ], + ], + [ + 'placeholders standing for at-rule conditions', + [ + 'const Queries = styled.div`', + ' @media screen and ${query} { color: red; }', + ' @media not all and ${query} { color: red; }', + ' @supports ${query} { color: red; }', + ' @supports not ${query} { color: red; }', + ' @container card ${query} { color: red; }', + ' @media screen and', + ' ${query} {', + ' color: red;', + ' }', + '`', + ], + ], + [ + 'value placeholders split over lines', + [ + 'const Tracks = styled.div`', + ' border:', + ' ${a}', + ' ${b};', + ' grid-template-columns:', + ' ${a}', + ' ${b};', + ' margin: 0;', + '`', + ], + ], + [ + 'selector placeholders and a commented @layer prelude', + [ + 'const Selectors = styled.div`', + ' &.${cls}:hover { color: red; }', + ' ${Child}:not(:last-child)', + ' {', + ' color: red;', + ' }', + ' @layer /* base styles */ base {', + ' color: red;', + ' }', + '`', + ], + ], + [ + 'JavaScript escapes, read as the characters styled-components receives', + [ + 'const Escapes = styled.div`', + ' &::before { content: \\"x\\"; }', + ' &::after { content: \\"a;b\\"; }', + ' color: red;\\n margin: 0;', + ' font-family: \\u{1F600};', + ' background: url(a\\\\b.png);', + ' color: red;\\', + ' margin: 0;', + '`', + ], + ], + [ + 'keyframe selectors, selector lists, media conditions, and url escapes', + [ + 'const Frames = keyframes`', + ' ${a}% { opacity: 0; }', + ' to { opacity: 1; }', + '`', + 'const Lists = styled.div`', + ' ${Child}:hover,', + ' ${Child}:focus {', + ' color: red;', + ' }', + ' @media ${query} or ${query} { color: red; }', + ' /* note */ @media screen and ${query} { color: red; }', + ' background: url(a\\\\(b\\\\).png);', + '`', + ], + ], + [ + 'JavaScript escapes inside unquoted url() arguments', + [ + 'const UrlEscapes = styled.div`', + ' background: url(a/\\x41y.png);', + ' background: url(x\\u0041y.png);', + ' background: url(foo\\', + 'bar.png);', + ' margin: 0;', + '`', + ], + ], + [ + 'mixins after JavaScript-escaped characters', + [ + 'const EscapedMixins = styled.div`', + ' content: "\\"; ${mixin};', + ' color: red\\; ${mixin};', + ' &:hover \\{ ${mixin}; }', + '`', + ], + ], + ])('should report no false error for %s', async (_description, templateLines) => { + const { diagnostics, marked } = await diagnose( + [ + 'declare const styled: any, keyframes: any, side: any, query: any, a: any, b: any, cls: any, Child: any, mixin: any', + ...templateLines, + ...controlLines, + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr', 'control')]) + }) + + it('should map a diagnostic after JavaScript escapes to its source position', async () => { + const { diagnostics, marked } = await diagnose( + [ + 'declare const styled: any', + 'const A = styled.div`', + ' &::before { content: \\"\\\\f101\\"; } ⟨colr⟩colr⟨/colr⟩: red;', + '`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr')]) + }) + + it('should report the cooked name of an escape inside a property name once, over the whole name', async () => { + const { diagnostics, marked } = await diagnose( + ['declare const styled: any', 'const A = styled.div`⟨name⟩co\\x6Cr⟨/name⟩: red;`'].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr', 'name')]) + }) + + it('should report nothing for a css fragment that is one word, and still report a declaration in one', async () => { + const { diagnostics, marked } = await diagnose( + [ + 'declare const css: any', + 'declare const x: number', + 'const a = css`none`', + 'const b = css`${x}px`', + 'const c = css`⟨colr⟩colr⟨/colr⟩: red;`', + ].join('\n'), + ) + + assert.deepEqual(spansAndText(diagnostics), [unknownProperty(marked, 'colr')]) + }) + + it('should report a diagnostic instead of throwing for an unclosed template in a file that is not open', async (context) => { + const closedFileServer = startServer(context, { project: 'closed-file-project-fixture' }) + const unclosedFile = closedFileServer.fixtureFile('unclosed.ts') + /** The file ends at its template's closing backtick, which is where the template ends. */ + const unclosedText = readFileSync(unclosedFile, 'utf8') + const templateEnd = mark(`${unclosedText.slice(0, -1)}⟨end⟩\``).at('end') + + /** + * Only the importing file is opened, so tsserver reads unclosed.ts from disk. The template + * context asserts on an out-of-range line for a file read this way, unlike an open file's + * in-memory content. + */ + closedFileServer.open("import './unclosed'\n", { fileName: 'entry.ts' }) + const response = await closedFileServer.request('semanticDiagnosticsSync', { + file: unclosedFile, + }) + + assert.deepEqual(spansAndText(pluginDiagnostics(response)), [ + { end: templateEnd, start: templateEnd, text: '} expected' }, + ]) + }) +}) diff --git a/test/e2e/scenarios/hover.test.ts b/test/e2e/scenarios/hover.test.ts new file mode 100644 index 0000000..b0f3087 --- /dev/null +++ b/test/e2e/scenarios/hover.test.ts @@ -0,0 +1,21 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, startServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +describe('Hover', () => { + it('should return CSS documentation for a property inside a tagged template', async (context) => { + const server = startServer(context) + const source = mark('const q = css`⟨declaration⟩co⟨caret⟩lor: red⟨/declaration⟩;`') + const file = server.open(source.text) + + const quickInfo = bodyOf(await server.request('quickinfo', { file, ...source.at('caret') })) + + const documentation = + typeof quickInfo.documentation === 'string' + ? quickInfo.documentation + : quickInfo.documentation.map((part) => part.text).join('') + assert.match(documentation, /Sets the color/i) + assert.deepEqual({ end: quickInfo.end, start: quickInfo.start }, source.range('declaration')) + }) +}) diff --git a/test/e2e/scenarios/markers.test.ts b/test/e2e/scenarios/markers.test.ts new file mode 100644 index 0000000..17cd926 --- /dev/null +++ b/test/e2e/scenarios/markers.test.ts @@ -0,0 +1,43 @@ +import ts from 'typescript' +import { assert, describe, it } from 'vitest' + +import { mark } from '../tsserver-fixture/markers' + +/** TypeScript's own mapping from a text index to a 1-based protocol position. */ +function typescriptLocation(text: string, index: number) { + const { character, line } = ts + .createSourceFile('probe.ts', text, ts.ScriptTarget.Latest) + .getLineAndCharacterOfPosition(index) + return { line: line + 1, offset: character + 1 } +} + +describe('Source markers', () => { + it.each([ + ['LF', '\n'], + ['CRLF', '\r\n'], + ['CR', '\r'], + ['line separator', String.fromCharCode(0x2028)], + ['paragraph separator', String.fromCharCode(0x2029)], + ])( + 'should place carets and ranges where TypeScript does after a %s break', + (_name, lineBreak) => { + const source = ['⟨first⟩a', 'bc⟨word⟩def⟨/word⟩', '', 'g⟨last⟩'].join(lineBreak) + const marked = mark(source) + const text = ['a', 'bcdef', '', 'g'].join(lineBreak) + + assert.strictEqual(marked.text, text) + assert.deepEqual(marked.at('first'), typescriptLocation(text, 0)) + assert.deepEqual(marked.range('word'), { + end: typescriptLocation(text, text.indexOf('def') + 3), + start: typescriptLocation(text, text.indexOf('def')), + }) + assert.deepEqual(marked.at('last'), typescriptLocation(text, text.length)) + }, + ) + + it('should fail loudly on a missing or repeated marker', () => { + assert.throws(() => mark('a').at('caret'), 'The source has no ⟨caret⟩ marker') + assert.throws(() => mark('⟨word⟩a').range('word'), 'The source has no ⟨/word⟩ marker') + assert.throws(() => mark('⟨a⟩x⟨a⟩'), 'The marker ⟨a⟩ appears twice') + }) +}) diff --git a/test/e2e/scenarios/outlining-spans.test.ts b/test/e2e/scenarios/outlining-spans.test.ts new file mode 100644 index 0000000..c327207 --- /dev/null +++ b/test/e2e/scenarios/outlining-spans.test.ts @@ -0,0 +1,25 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, startServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +describe('Outlining spans', () => { + it('should return basic CSS outlining spans', async (context) => { + const server = startServer(context) + const source = mark( + ['const q = css`', '⟨a⟩a {', '⟨/a⟩color: red;', '}', '⟨div⟩div {', '⟨/div⟩', '}', '`'].join( + '\n', + ), + ) + const file = server.open(source.text) + + const spans = bodyOf(await server.request('getOutliningSpans', { file })) + + /** TypeScript contributes the first span, for the multiline template literal itself. */ + assert.lengthOf(spans, 3) + assert.deepEqual( + spans.slice(1).map(({ textSpan }) => textSpan), + [source.range('a'), source.range('div')], + ) + }) +}) diff --git a/test/e2e/scenarios/plugin-lifecycle.test.ts b/test/e2e/scenarios/plugin-lifecycle.test.ts new file mode 100644 index 0000000..a7ce2ac --- /dev/null +++ b/test/e2e/scenarios/plugin-lifecycle.test.ts @@ -0,0 +1,230 @@ +import type { server as tsserver } from 'typescript' +import { assert, describe, it } from 'vitest' + +import { + bodyOf, + pluginDiagnosticCode, + pluginDiagnostics, + spansAndText, + startServer, +} from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +describe.concurrent('Plugin lifecycle', () => { + it('should keep tsserver responsive when a configured plugin can’t be found (a tsserver guard, not this plugin’s own resilience)', async (context) => { + const server = startServer(context, { project: 'plugin-missing-project-fixture' }) + const source = mark('const value = 1⟨caret⟩') + const file = server.open(source.text) + + const completions = await server.request('completions', { file, ...source.at('caret') }) + await server.close() + + /** + * The fixture's tsconfig names a plugin that does not exist, so this plugin never loads here: + * the successful response shows tsserver's own resilience, which the log line confirms. + */ + assert.match(server.readLog(), /Failed to load module 'missing-tsserver-plugin'/) + assert.isTrue(completions.success) + }) + + it('should ignore malformed plugin configuration without crashing tsserver, logging each rejected value', async (context) => { + const server = startServer(context) + const source = mark('const q = css`color:⟨caret⟩`') + const file = server.open(source.text) + + await server.configurePlugin({ + emmet: null, + lint: { unknownProperties: 0, vendorPrefix: 'off' }, + tag: 'styled', + tags: 'css', + validate: 'false', + }) + const completions = await server.request('completions', { file, ...source.at('caret') }) + await server.close() + + /** + * Positive control in the same session: a real CSS completion proves the plugin kept + * answering under the single-string `tags` alias. + */ + assert.include( + bodyOf(completions).map(({ name }) => name), + 'aliceblue', + ) + const log = server.readLog() + assert.include( + log, + 'Ignored the plugin setting emmet: it accepts an object of Emmet options; received null. The default applies.', + ) + assert.include( + log, + 'Ignored the plugin setting lint.unknownProperties: it accepts "ignore", "warning", or "error"; received 0. The CSS language service\'s own default for this rule applies.', + ) + assert.include( + log, + 'Ignored the plugin setting tag: the plugin has no setting with that name. Did you mean tags? The plugin settings are emmet, lint, tags, and validate.', + ) + assert.notInclude(log, 'Ignored the plugin setting lint.vendorPrefix') + assert.notInclude(log, 'Ignored the plugin setting tags') + assert.notInclude(log, 'Ignored the plugin setting validate') + /** The fixture's tsconfig plugins entry, which tsserver passes whole, carries `name`. */ + assert.notInclude(log, 'Ignored the plugin setting name') + }) + + it('should apply and reset tags through the template decorator configuration', async (context) => { + const server = startServer(context) + const source = mark('const q = sty`color:⟨caret⟩`') + const file = server.open(source.text) + const complete = () => server.request('completions', { file, ...source.at('caret') }) + + const defaultTags = await complete() + await server.configurePlugin({ tags: ['sty'] }) + const customTags = await complete() + await server.configurePlugin({}) + const resetTags = await complete() + + assert.isFalse(defaultTags.success) + /** Positive control in the same session: the configured tag gets CSS values. */ + assert.include( + bodyOf(customTags).map(({ name }) => name), + 'aliceblue', + ) + assert.isFalse(resetTags.success) + }) + + it('should apply validation configuration changes to diagnostics and code fixes', async (context) => { + const server = startServer(context) + const source = mark('const q = css`⟨property⟩boarder⟨/property⟩: 1px solid black;`') + const file = server.open(source.text) + const property = source.range('property') + const diagnose = async () => + spansAndText(pluginDiagnostics(await server.request('semanticDiagnosticsSync', { file }))) + /** The plugin's part of each fix; tsserver adds its own `fixName`. */ + const fix = async (): Promise => + bodyOf( + await server.request('getCodeFixes', { + endLine: property.end.line, + endOffset: property.end.offset, + errorCodes: [pluginDiagnosticCode], + file, + startLine: property.start.line, + startOffset: property.start.offset, + }), + ).map(({ changes, description }) => ({ changes, description })) + const expectedDiagnostics = [{ ...property, text: "Unknown property: 'boarder'" }] + /** vscode-css-languageservice ranks these three by similarity to "boarder". */ + const expectedFixes = ['border', 'border-top', 'border-left'].map((name) => ({ + changes: [{ fileName: file, textChanges: [{ newText: name, ...property }] }], + description: `Rename to '${name}'`, + })) + + const enabled = { diagnostics: await diagnose(), fixes: await fix() } + await server.configurePlugin({ validate: false }) + const disabled = { diagnostics: await diagnose(), fixes: await fix() } + await server.configurePlugin({}) + const reset = { diagnostics: await diagnose(), fixes: await fix() } + + /** The enabled and reset states are the positive controls for the silent disabled state. */ + assert.deepEqual(enabled, { diagnostics: expectedDiagnostics, fixes: expectedFixes }) + assert.deepEqual(disabled, { diagnostics: [], fixes: [] }) + assert.deepEqual(reset, { diagnostics: expectedDiagnostics, fixes: expectedFixes }) + }) + + it.for([ + [ + 'unknownProperties', + 'const q = css`⟨subject⟩boarder⟨/subject⟩: 1px solid black;`', + "Unknown property: 'boarder'", + ], + [ + 'unknownAtRules', + 'const q = css`⟨subject⟩@not-a-rule⟨/subject⟩ { color: red; }`', + 'Unknown at rule @not-a-rule', + ], + ])( + 'should apply CSS lint levels for %s through plugin configuration', + async ([rule, sourceText, text], context) => { + const server = startServer(context) + const source = mark(sourceText) + const file = server.open(source.text) + + const categories: Record = {} + for (const level of ['ignore', 'warning', 'error']) { + await server.configurePlugin({ lint: { [rule]: level } }) + const diagnostics = pluginDiagnostics( + await server.request('semanticDiagnosticsSync', { file }), + ) + assert.deepEqual( + spansAndText(diagnostics), + level === 'ignore' ? [] : [{ ...source.range('subject'), text }], + ) + categories[level] = diagnostics.map(({ category }) => category) + } + + /** The warning and error levels are the positive controls for the silent ignore level. */ + assert.deepEqual(categories, { error: ['error'], ignore: [], warning: ['warning'] }) + }, + ) + + it('should not report a diagnostic for an empty template even when empty rulesets are linted', async (context) => { + /** + * For an empty template, vscode-css-languageservice reports "Do not use empty rulesets" on the + * synthetic wrapper rule, which has no template counterpart, so the plugin drops it. + */ + const server = startServer(context) + const source = mark('const empty = css``; const nested = css`⟨rule⟩a⟨/rule⟩ {}`') + const file = server.open(source.text) + + await server.configurePlugin({ lint: { emptyRules: 'error' } }) + const diagnostics = pluginDiagnostics(await server.request('semanticDiagnosticsSync', { file })) + + /** + * Positive control in the same session: the real empty ruleset in the second template is + * reported, so the setting took effect and the plugin loaded. + */ + assert.deepEqual(spansAndText(diagnostics), [ + { ...source.range('rule'), text: 'Do not use empty rulesets' }, + ]) + }) + + it('should apply and reset valid CSS properties through plugin configuration', async (context) => { + const server = startServer(context) + const source = mark('const q = css`⟨property⟩brand-tone⟨/property⟩: red;`') + const file = server.open(source.text) + + await server.configurePlugin({ lint: { validProperties: ['brand-tone'] } }) + const custom = pluginDiagnostics(await server.request('semanticDiagnosticsSync', { file })) + await server.configurePlugin({}) + const reset = pluginDiagnostics(await server.request('semanticDiagnosticsSync', { file })) + + assert.deepEqual(custom, []) + /** Positive control in the same session: without the setting, the property is reported. */ + assert.deepEqual(spansAndText(reset), [ + { ...source.range('property'), text: "Unknown property: 'brand-tone'" }, + ]) + }) + + it('should not activate on a TypeScript host below the 5.0 floor', async (context) => { + const server = startServer(context, { typescriptPackage: 'typescript-legacy' }) + const source = mark('const q = css`boarder: 1px solid black;`; const r = css`color:⟨caret⟩`') + const file = server.open(source.text) + + const diagnostics = await server.request('semanticDiagnosticsSync', { file }) + const completions = await server.request('completions', { file, ...source.at('caret') }) + await server.close() + + /** + * The protocol responses alone cannot tell "the plugin chose not to activate" from "the plugin + * failed to load": both answer without the plugin's diagnostics and completions. The log line + * is the control: the plugin loaded, read the host version, and logged its refusal. + */ + assert.match( + server.readLog(), + /\[ts-styled-plugin\] Unsupported TypeScript version .* TypeScript 5\.0 or newer required/, + ) + assert.deepEqual(pluginDiagnostics(diagnostics), []) + assert.notInclude( + bodyOf(completions).map(({ name }) => name), + 'aliceblue', + ) + }) +}) diff --git a/test/e2e/scenarios/script-kinds.test.ts b/test/e2e/scenarios/script-kinds.test.ts new file mode 100644 index 0000000..fb1175b --- /dev/null +++ b/test/e2e/scenarios/script-kinds.test.ts @@ -0,0 +1,29 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, useSharedServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' + +describe('Script kinds', () => { + const server = useSharedServer() + + it.each([ + ['JavaScript', 'script-kind-js.js', 'JS'], + ['TypeScript', 'main.ts', 'TS'], + ['JSX', 'script-kind-jsx.jsx', 'JSX'], + ['TSX', 'script-kind-tsx.tsx', 'TSX'], + ] as const)('should provide CSS completions in %s', async (name, fileName, scriptKindName) => { + const source = mark(`const q = css\`color:⟨caret⟩\` // ${name}`) + const file = server().open(source.text, { fileName, scriptKindName }) + + const completions = bodyOf( + await server().request('completions', { file, ...source.at('caret') }), + ) + + const aliceblue = completions.find((item) => item.name === 'aliceblue') + assert.isDefined(aliceblue) + assert.deepEqual(aliceblue.replacementSpan, { + end: source.at('caret'), + start: source.at('caret'), + }) + }) +}) diff --git a/test/e2e/scenarios/styled-components-syntax.test.ts b/test/e2e/scenarios/styled-components-syntax.test.ts new file mode 100644 index 0000000..4e8ebc6 --- /dev/null +++ b/test/e2e/scenarios/styled-components-syntax.test.ts @@ -0,0 +1,58 @@ +import { assert, describe, it } from 'vitest' + +import { bodyOf, startServer, useSharedServer } from '../tsserver-fixture/helpers' +import { mark } from '../tsserver-fixture/markers' +import type { TSServer } from '../tsserver-fixture/server' + +async function namesAtCaret(server: TSServer, source: string): Promise { + const marked = mark(source) + const file = server.open(marked.text) + const response = await server.request('completions', { file, ...marked.at('caret') }) + return bodyOf(response).map(({ name }) => name) +} + +describe('Styled-components syntax', () => { + const server = useSharedServer() + + it.each([ + ['styled.div', 'const Button = styled.div`color:⟨caret⟩`'], + ['styled(Component)', 'const Button = styled(Component)`color:⟨caret⟩`'], + ['css', 'const rules = css`color:⟨caret⟩`'], + ['keyframes', 'const animation = keyframes`0% { color:⟨caret⟩ }`'], + ['styled.keyframes', 'const animation = styled.keyframes`0% { color:⟨caret⟩ }`'], + ['createGlobalStyle', 'const GlobalStyle = createGlobalStyle`color:⟨caret⟩`'], + ['injectGlobal', 'injectGlobal`color:⟨caret⟩`'], + ['extend', 'const Extended = Button.extend`color:⟨caret⟩`'], + ['globalCss (Pigment CSS)', 'globalCss`color:⟨caret⟩`'], + ])('should provide CSS completions for %s', async (_name, source) => { + assert.include(await namesAtCaret(server(), source), 'aliceblue') + }) + + it('should not infer keyframes semantics from an alias', async () => { + const source = mark( + [ + 'const kf = keyframes; const animation = kf`0% { color:⟨alias⟩ }`', + 'const direct = keyframes`0% { color:⟨direct⟩ }`', + ].join('\n'), + ) + const file = server().open(source.text) + + const alias = await server().request('completions', { file, ...source.at('alias') }) + const direct = await server().request('completions', { file, ...source.at('direct') }) + + assert.deepInclude(alias, { message: 'No content available.', success: false }) + /** Positive control in the same file: the configured tag itself still gets CSS values. */ + assert.include( + bodyOf(direct).map(({ name }) => name), + 'aliceblue', + ) + }) +}) + +describe('Default tag configuration', () => { + it('should provide CSS completions for globalCss without an explicit tags list', async (context) => { + const server = startServer(context, { project: 'default-tags-project-fixture' }) + + assert.include(await namesAtCaret(server, 'globalCss`color:⟨caret⟩`'), 'aliceblue') + }) +}) diff --git a/test/e2e/styled-project-fixture/main.ts b/test/e2e/styled-project-fixture/main.ts new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/styled-project-fixture/main.ts @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/styled-project-fixture/script-kind-js.js b/test/e2e/styled-project-fixture/script-kind-js.js new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/styled-project-fixture/script-kind-js.js @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/styled-project-fixture/script-kind-jsx.jsx b/test/e2e/styled-project-fixture/script-kind-jsx.jsx new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/styled-project-fixture/script-kind-jsx.jsx @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/styled-project-fixture/script-kind-tsx.tsx b/test/e2e/styled-project-fixture/script-kind-tsx.tsx new file mode 100644 index 0000000..336ce12 --- /dev/null +++ b/test/e2e/styled-project-fixture/script-kind-tsx.tsx @@ -0,0 +1 @@ +export {} diff --git a/test/e2e/styled-project-fixture/tsconfig.json b/test/e2e/styled-project-fixture/tsconfig.json new file mode 100644 index 0000000..450d6a9 --- /dev/null +++ b/test/e2e/styled-project-fixture/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowJs": true, + "jsx": "preserve", + "noEmit": true, + "target": "ES2021", + "module": "commonjs", + "strict": true, + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "tags": [ + "styled", + "css", + "extend", + "injectGlobal", + "createGlobalStyle", + "globalCss", + "keyframes" + ] + } + ] + } +} diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json new file mode 100644 index 0000000..336a316 --- /dev/null +++ b/test/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "preserve" + }, + "include": ["fixture-paths.ts", "tsserver-fixture", "scenarios"], + // Replaces the base config's exclude, which lists test/e2e and would otherwise exclude every file here. + "exclude": [] +} diff --git a/test/e2e/tsserver-fixture/check-plugin-link.ts b/test/e2e/tsserver-fixture/check-plugin-link.ts new file mode 100644 index 0000000..a3c3f62 --- /dev/null +++ b/test/e2e/tsserver-fixture/check-plugin-link.ts @@ -0,0 +1,25 @@ +import { realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import * as path from 'node:path' + +import { e2eRoot, repositoryRoot } from '../fixture-paths' +import { pluginPackageName } from './server' + +/** + * Vitest globalSetup for the e2e project. tsserver loads the plugin by resolving its package name + * from the e2e root (`--pluginProbeLocations`), so a workspace link pointing anywhere else (another + * checkout, a stale copy) would silently test that other build. Node's resolution from the same + * directory walks the same `node_modules` chain and returns the real path. + */ +export default function checkPluginLink() { + const manifest = createRequire(path.join(e2eRoot, 'package.json')).resolve( + `${pluginPackageName}/package.json`, + ) + const resolvedRoot = realpathSync(path.dirname(manifest)) + const expectedRoot = realpathSync(repositoryRoot) + if (resolvedRoot !== expectedRoot) { + throw new Error( + `The e2e workspace resolves ${pluginPackageName} to ${resolvedRoot}, not this repository (${expectedRoot}), so the scenarios would test that other build. Run \`corepack yarn install\` in this repository to relink test/e2e/node_modules/${pluginPackageName} to it.`, + ) + } +} diff --git a/test/e2e/tsserver-fixture/clear-logs.ts b/test/e2e/tsserver-fixture/clear-logs.ts new file mode 100644 index 0000000..8abac67 --- /dev/null +++ b/test/e2e/tsserver-fixture/clear-logs.ts @@ -0,0 +1,51 @@ +import { readdirSync, rmSync } from 'node:fs' +import * as path from 'node:path' + +const logsRoot = path.join(import.meta.dirname, 'logs') + +function errorCode(error: unknown): unknown { + return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined +} + +/** + * `kill(pid, 0)` probes liveness without signaling. Any error other than "no such process" (EPERM + * against another user's process, for example) counts as alive, so a live run's logs are never + * removed. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return errorCode(error) !== 'ESRCH' + } +} + +/** + * Vitest globalSetup for the e2e project, run in the orchestrator before any worker starts; the + * workers inherit `process.env` from it. Sets TSSERVER_FIXTURE_RUN_ID to the orchestrator's pid, + * which gives this run's tsserver logs their own directory, so two e2e runs at once never delete + * each other's logs. Removes every other run directory whose owning process has exited, and any + * entry that is not a pid directory; a failed run's logs stay until the next e2e run. + */ +export default function clearTSServerLogs() { + process.env.TSSERVER_FIXTURE_RUN_ID = String(process.pid) + + let entries: string[] + try { + entries = readdirSync(logsRoot) + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return + } + throw error + } + + for (const entry of entries) { + const pid = Number(entry) + if (Number.isInteger(pid) && isProcessAlive(pid)) { + continue + } + rmSync(path.join(logsRoot, entry), { force: true, recursive: true }) + } +} diff --git a/test/e2e/tsserver-fixture/helpers.ts b/test/e2e/tsserver-fixture/helpers.ts new file mode 100644 index 0000000..22285ae --- /dev/null +++ b/test/e2e/tsserver-fixture/helpers.ts @@ -0,0 +1,101 @@ +import type { server } from 'typescript' +import { afterAll, beforeAll, type TestContext } from 'vitest' + +import { TSServer, type TSServerOptions } from './server' + +/** The `source` tsserver reports on this plugin's diagnostics. */ +const pluginSource = 'ts-styled-plugin' + +/** The `code` of every diagnostic this plugin reports, and the one its code fixes answer to. */ +export const pluginDiagnosticCode = 9999 + +/** TypeScript ends a line at each of these, so a table covering one covers both. */ +export const unicodeLineBreaks = [ + ['line separator', String.fromCharCode(0x2028)], + ['paragraph separator', String.fromCharCode(0x2029)], +] as const + +/** Asserts the response succeeded and carries a body, and returns that body. */ +export function bodyOf( + response: Response, +): NonNullable { + if (!response.success) { + throw new Error( + `Expected a successful "${response.command}" response; tsserver answered: ${response.message ?? 'no message'}`, + ) + } + const body = response.body + if (body === undefined || body === null) { + throw new Error(`Expected the "${response.command}" response to carry a body.`) + } + return body +} + +/** Every diagnostic in the response, TypeScript's and this plugin's. */ +export function diagnosticsOf( + response: server.protocol.SemanticDiagnosticsSyncResponse, +): server.protocol.Diagnostic[] { + const diagnostics: readonly ( + | server.protocol.Diagnostic + | server.protocol.DiagnosticWithLinePosition + )[] = bodyOf(response) + return diagnostics.map((diagnostic) => { + if (!('text' in diagnostic)) { + throw new Error( + 'Expected line and offset diagnostics; the request must not set includeLinePosition.', + ) + } + return diagnostic + }) +} + +export function pluginDiagnostics( + response: server.protocol.SemanticDiagnosticsSyncResponse, +): server.protocol.Diagnostic[] { + return diagnosticsOf(response).filter((diagnostic) => diagnostic.source === pluginSource) +} + +/** + * The span and message of each diagnostic in source order, for a full-list assertion. tsserver's + * order is not part of the contract (editors sort diagnostics themselves). + */ +export function spansAndText(diagnostics: readonly server.protocol.Diagnostic[]) { + return diagnostics + .map(({ end, start, text }) => ({ end, start, text })) + .sort( + (left, right) => + left.start.line - right.start.line || + left.start.offset - right.start.offset || + left.end.line - right.end.line || + left.end.offset - right.end.offset || + left.text.localeCompare(right.text), + ) +} + +/** + * One tsserver for every test in the enclosing suite, started before the first and closed after + * the last. Its tests run in sequence and each reopens the file it reads with its own content. + */ +export function useSharedServer(options?: TSServerOptions): () => TSServer { + let shared: TSServer | undefined + beforeAll(() => { + shared = new TSServer(options) + }) + afterAll(() => shared?.close()) + return () => { + if (shared === undefined) { + throw new Error('The shared tsserver starts in beforeAll; read it inside a test.') + } + return shared + } +} + +/** A tsserver for one test, closed when that test finishes, including when it fails. */ +export function startServer( + context: Pick, + options?: TSServerOptions, +): TSServer { + const server = new TSServer(options) + context.onTestFinished(() => server.close()) + return server +} diff --git a/test/e2e/tsserver-fixture/markers.ts b/test/e2e/tsserver-fixture/markers.ts new file mode 100644 index 0000000..28e6ec9 --- /dev/null +++ b/test/e2e/tsserver-fixture/markers.ts @@ -0,0 +1,74 @@ +import type { server } from 'typescript' + +/** `⟨name⟩` opens a marker (or marks a caret); `⟨/name⟩` closes the range `name` opened. */ +const markerPattern = /⟨(\/?)([^⟨⟩]+)⟩/g + +export interface MarkedSource { + /** The caret `⟨name⟩` marks, as a tsserver protocol position. */ + at(name: string): server.protocol.Location + /** The span from `⟨name⟩` to `⟨/name⟩`, as a tsserver protocol span. */ + range(name: string): server.protocol.TextSpan + /** The source with every marker removed. */ + text: string +} + +/** + * Removes the markers from `source` and returns its text plus the protocol positions they mark: + * a 1-based line and a 1-based UTF-16 offset, with lines split the way tsserver splits them. + */ +export function mark(source: string): MarkedSource { + const indexes = new Map() + let text = '' + let last = 0 + for (const match of source.matchAll(markerPattern)) { + const key = `${match[1]}${match[2]}` + if (indexes.has(key)) { + throw new Error(`The marker ⟨${key}⟩ appears twice; give each marker a distinct name.`) + } + text += source.slice(last, match.index) + indexes.set(key, text.length) + last = match.index + match[0].length + } + text += source.slice(last) + + const lineStarts = computeLineStarts(text) + const find = (key: string) => { + const index = indexes.get(key) + if (index === undefined) { + throw new Error(`The source has no ⟨${key}⟩ marker. Add it where the position belongs.`) + } + return toLocation(lineStarts, index) + } + + return { + at: (name) => find(name), + range: (name) => ({ end: find(`/${name}`), start: find(name) }), + text, + } +} + +/** + * TypeScript's scanner ends a line at LF, CR, CRLF (one break), U+2028, and U+2029, so protocol + * lines count the two Unicode separators too. + */ +function computeLineStarts(text: string): number[] { + const starts = [0] + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index) + if (code === 0x0d && text.charCodeAt(index + 1) === 0x0a) { + index++ + starts.push(index + 1) + } else if (code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029) { + starts.push(index + 1) + } + } + return starts +} + +function toLocation(lineStarts: readonly number[], index: number): server.protocol.Location { + let line = 0 + while (line + 1 < lineStarts.length && (lineStarts[line + 1] ?? Infinity) <= index) { + line++ + } + return { line: line + 1, offset: index - (lineStarts[line] ?? 0) + 1 } +} diff --git a/test/e2e/tsserver-fixture/message-reader.ts b/test/e2e/tsserver-fixture/message-reader.ts new file mode 100644 index 0000000..877b42a --- /dev/null +++ b/test/e2e/tsserver-fixture/message-reader.ts @@ -0,0 +1,34 @@ +const headerSeparator = Buffer.from('\r\n\r\n') + +export class TSServerMessageReader { + private buffer = Buffer.alloc(0) + + push(chunk: Buffer): string[] { + this.buffer = Buffer.concat([this.buffer, chunk]) + const messages: string[] = [] + + while (this.buffer.length > 0) { + const headerEnd = this.buffer.indexOf(headerSeparator) + if (headerEnd === -1) { + break + } + + const header = this.buffer.subarray(0, headerEnd).toString('ascii') + const contentLengthText = /^Content-Length: (\d+)$/im.exec(header)?.[1] + if (contentLengthText === undefined) { + throw new Error(`tsserver wrote a header with no "Content-Length": ${header}`) + } + + const bodyStart = headerEnd + headerSeparator.length + const messageEnd = bodyStart + Number(contentLengthText) + if (this.buffer.length < messageEnd) { + break + } + + messages.push(this.buffer.subarray(bodyStart, messageEnd).toString('utf8')) + this.buffer = this.buffer.subarray(messageEnd) + } + + return messages + } +} diff --git a/test/e2e/tsserver-fixture/server.ts b/test/e2e/tsserver-fixture/server.ts new file mode 100644 index 0000000..62fd268 --- /dev/null +++ b/test/e2e/tsserver-fixture/server.ts @@ -0,0 +1,361 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdirSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import * as path from 'node:path' + +import type { server } from 'typescript' + +import { e2eRoot, type FixtureProject } from '../fixture-paths' +import { TSServerMessageReader } from './message-reader' +import { responseTimeoutMs } from './timeouts' + +const require = createRequire(import.meta.url) + +export const pluginPackageName = '@styled/typescript-styled-plugin' + +/** + * V8 heap cap for each tsserver, several times what a fixture project's server uses, so a plugin + * regression that retains without bound ends that server with "JavaScript heap out of memory" in + * the failure message instead of growing every parallel server toward swap. + */ +const TSSERVER_HEAP_CAP_MB = 1024 + +/** Requests the scenarios send, each paired with the response tsserver's protocol declares for it. */ +interface RequestTypes { + completionEntryDetails: { + request: server.protocol.CompletionDetailsRequest + response: server.protocol.CompletionDetailsResponse + } + completions: { + request: server.protocol.CompletionsRequest + response: server.protocol.CompletionsResponse + } + configure: { + request: server.protocol.ConfigureRequest + response: server.protocol.ConfigureResponse + } + configurePlugin: { + request: server.protocol.ConfigurePluginRequest + response: server.protocol.ConfigurePluginResponse + } + getCodeFixes: { + request: server.protocol.CodeFixRequest + response: server.protocol.GetCodeFixesResponse + } + getOutliningSpans: { + request: server.protocol.OutliningSpansRequest + response: server.protocol.OutliningSpansResponse + } + quickinfo: { + request: server.protocol.QuickInfoRequest + response: server.protocol.QuickInfoResponse + } + semanticDiagnosticsSync: { + request: server.protocol.SemanticDiagnosticsSyncRequest + response: server.protocol.SemanticDiagnosticsSyncResponse + } +} + +/** Messages tsserver applies without a required response. */ +interface NotificationTypes { + change: server.protocol.ChangeRequest + open: server.protocol.OpenRequest +} + +export type RequestCommand = keyof RequestTypes +export type RequestArguments = + RequestTypes[Command]['request']['arguments'] +export type ResponseTo = RequestTypes[Command]['response'] +type NotificationCommand = keyof NotificationTypes + +export interface TSServerOptions { + project?: FixtureProject + /** An alias from test/e2e/package.json; defaults to `TSSERVER_TYPESCRIPT_PACKAGE`, then `typescript`. */ + typescriptPackage?: string +} + +interface PendingRequest { + command: RequestCommand + reject: (reason: Error) => void + resolve: (response: server.protocol.Response) => void + timeout: NodeJS.Timeout +} + +export class TSServer { + private closePromise: Promise | undefined + private readonly exited: Promise + private failure: Error | undefined + /** + * Sequence numbers of sent notifications. Some TypeScript versions answer a notification with a + * response and others send none (docs/tsserver-host.md), so an answer is consumed if it arrives + * and never awaited. + */ + private readonly notifications = new Set() + private readonly pending = new Map() + private readonly process: ChildProcessWithoutNullStreams + private sequence = 0 + public readonly logFile: string + public readonly project: FixtureProject + + constructor({ project = 'styled-project-fixture', typescriptPackage }: TSServerOptions = {}) { + this.project = project + const runId = process.env.TSSERVER_FIXTURE_RUN_ID + if (runId === undefined) { + throw new Error( + 'TSSERVER_FIXTURE_RUN_ID is not set: the e2e globalSetup (test/e2e/tsserver-fixture/clear-logs.ts) sets it. Run e2e scenarios through the e2e vitest project, for example `corepack yarn test:e2e`.', + ) + } + const logDirectory = path.join(import.meta.dirname, 'logs', runId) + mkdirSync(logDirectory, { recursive: true }) + this.logFile = path.join(logDirectory, `${randomUUID()}.log`) + + const packageName = typescriptPackage ?? process.env.TSSERVER_TYPESCRIPT_PACKAGE ?? 'typescript' + const tsserverPath = require.resolve(`${packageName}/lib/tsserver.js`) + this.process = spawn( + process.execPath, + [ + `--max-old-space-size=${TSSERVER_HEAP_CAP_MB}`, + tsserverPath, + '--logVerbosity', + 'verbose', + '--logFile', + this.logFile, + '--pluginProbeLocations', + e2eRoot, + ], + { cwd: path.join(e2eRoot, project), stdio: 'pipe' }, + ) + + let stderr = '' + this.process.stderr.on('data', (chunk: Buffer) => { + stderr = (stderr + chunk.toString('utf8')).slice(-8_192) + }) + const messageReader = new TSServerMessageReader() + this.process.stdout.on('data', (chunk: Buffer) => { + for (const message of messageReader.push(chunk)) { + this.handleMessage(message) + } + }) + this.exited = new Promise((resolve) => { + this.process.on('error', (error) => { + this.fail(new Error(`tsserver failed to start from ${tsserverPath}.`, { cause: error })) + }) + this.process.on('close', (code, signal) => { + const closing = this.closePromise !== undefined + if (!closing || code !== 0 || signal !== null) { + this.fail( + new Error( + `tsserver ${closing ? 'exited' : 'exited before close()'} with code ${String(code)} and signal ${String(signal)}. Its log: ${this.logFile}${stderr ? `\n${stderr}` : ''}`, + ), + ) + } + resolve() + }) + }) + } + + /** Path of a file in this server's fixture project. */ + fixtureFile(fileName = 'main.ts'): string { + return path.join(e2eRoot, this.project, fileName) + } + + /** + * Sends `command` and resolves with the response whose `request_seq` matches it. A response + * that never arrives fails the whole session and stops tsserver at once: a plugin stuck in a + * synchronous loop never reads stdin, so waiting for `close()` to end it would hold a core for + * another full timeout. + */ + request( + command: Command, + args: RequestArguments, + ): Promise> { + const seq = this.write(command, args) + return new Promise>((resolve, reject) => { + const timeout = setTimeout(() => { + this.fail( + new Error( + `tsserver did not answer "${command}" (request_seq ${seq}) within ${responseTimeoutMs} ms, so it was stopped. Raise TSSERVER_RESPONSE_TIMEOUT_MS on a slow host, or read the log: ${this.logFile}`, + ), + ) + }, responseTimeoutMs) + this.pending.set(seq, { command, reject, resolve, timeout }) + }) + } + + notify( + command: Command, + args: NotificationTypes[Command]['arguments'], + ): void { + this.notifications.add(this.write(command, args)) + } + + /** Opens `fileName` in this server's fixture project with `fileContent`, returning its path. */ + open( + fileContent: string, + { + fileName = 'main.ts', + scriptKindName = 'TS', + }: { fileName?: string; scriptKindName?: server.protocol.ScriptKindName } = {}, + ): string { + const file = this.fixtureFile(fileName) + this.notify('open', { file, fileContent, scriptKindName }) + return file + } + + async configurePlugin(configuration: unknown): Promise { + const response = await this.request('configurePlugin', { + configuration, + pluginName: pluginPackageName, + }) + if (!response.success) { + throw new Error(`configurePlugin failed: ${response.message ?? 'no message'}`) + } + } + + /** Ends tsserver's input and waits for it to exit; rejects on any failure the session saw. */ + close(): Promise { + this.closePromise ??= this.shutDown() + return this.closePromise + } + + /** Reads this server's own tsserver log; throws when tsserver never wrote it. */ + readLog(): string { + return readFileSync(this.logFile, 'utf8') + } + + private async shutDown(): Promise { + if (this.pending.size > 0) { + this.fail( + new Error( + `close() ran while requests still awaited responses: ${this.describePending()}. Await each request before closing.`, + ), + ) + } else { + this.process.stdin.end() + } + + let timeout: NodeJS.Timeout | undefined + const timedOut = new Promise<'timed out'>((resolve) => { + timeout = setTimeout(() => resolve('timed out'), responseTimeoutMs) + }) + const outcome = await Promise.race([this.exited, timedOut]) + clearTimeout(timeout) + if (outcome === 'timed out') { + this.process.kill('SIGKILL') + throw new Error( + `tsserver did not exit within ${responseTimeoutMs} ms of close(). Its log: ${this.logFile}`, + ) + } + if (this.failure !== undefined) { + throw this.failure + } + } + + private write(command: string, args: unknown): number { + if (this.failure !== undefined) { + throw this.failure + } + if (this.closePromise !== undefined) { + throw new Error(`Cannot send "${command}": close() already ran for this server.`) + } + const seq = ++this.sequence + let message = JSON.stringify({ arguments: args, command, seq, type: 'request' }) + /** The line-delimited protocol reads a raw U+2028 or U+2029 as a line break. */ + for (const code of [0x2028, 0x2029]) { + message = message.replaceAll(String.fromCharCode(code), `\\u${code.toString(16)}`) + } + this.process.stdin.write(`${message}\n`) + return seq + } + + private handleMessage(text: string): void { + let message: unknown + try { + message = JSON.parse(text) + } catch (error) { + this.fail( + new Error(`tsserver wrote a message that is not JSON: ${text.slice(0, 500)}`, { + cause: error, + }), + ) + return + } + if (isEvent(message)) { + return + } + if (!isResponse(message)) { + this.fail( + new Error( + `tsserver wrote a message that is neither a response nor an event: ${text.slice(0, 500)}`, + ), + ) + return + } + if (this.notifications.delete(message.request_seq)) { + return + } + const pending = this.pending.get(message.request_seq) + if (pending === undefined) { + this.fail( + new Error( + `tsserver answered request_seq ${message.request_seq} ("${message.command}"), which no request awaits.`, + ), + ) + return + } + this.pending.delete(message.request_seq) + clearTimeout(pending.timeout) + if (message.command !== pending.command) { + pending.reject( + new Error( + `tsserver answered request_seq ${message.request_seq} with a "${message.command}" response; the request was "${pending.command}".`, + ), + ) + return + } + pending.resolve(message) + } + + /** Records the first failure, rejects every awaiting request with it, and stops tsserver. */ + private fail(error: Error): void { + this.failure ??= error + for (const [seq, pending] of this.pending) { + clearTimeout(pending.timeout) + pending.reject(this.failure) + this.pending.delete(seq) + } + if (this.process.exitCode === null && this.process.signalCode === null) { + this.process.kill() + } + } + + private describePending(): string { + return [...this.pending] + .map(([seq, { command }]) => `"${command}" (request_seq ${seq})`) + .join(', ') + } +} + +function isEvent(message: unknown): boolean { + return ( + typeof message === 'object' && message !== null && 'type' in message && message.type === 'event' + ) +} + +function isResponse(message: unknown): message is server.protocol.Response { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'response' && + 'seq' in message && + typeof message.seq === 'number' && + 'request_seq' in message && + typeof message.request_seq === 'number' && + 'command' in message && + typeof message.command === 'string' && + 'success' in message && + typeof message.success === 'boolean' + ) +} diff --git a/test/package-api/consumer-1.0.1-shape.cts b/test/package-api/consumer-1.0.1-shape.cts new file mode 100644 index 0000000..d5d6f5b --- /dev/null +++ b/test/package-api/consumer-1.0.1-shape.cts @@ -0,0 +1,52 @@ +/** + * A consumer written exactly as one compiled against 1.0.1's published types would be: the root + * factory's result invoked without an optional-chained onConfigurationChanged, a + * StyledPluginConfiguration built with lint/emmet values 1.0.1's open `{ [key: string]: any }` + * shape accepted (a rule or option this plugin does not itself know about, and an arbitrary index + * access), and a VirtualDocumentProvider implementation whose methods omit the optional context + * parameter the way an implementation predating it would. + */ +import api = require('@styled/typescript-styled-plugin/api') +import plugin = require('@styled/typescript-styled-plugin') +import ts = require('typescript/lib/tsserverlibrary.js') + +const configuredPlugin = plugin({ typescript: ts }) +configuredPlugin.onConfigurationChanged({ tags: ['styled'] }) + +const configuration: api.StyledPluginConfiguration = { + emmet: { customKey: 1, showExpandedAbbreviation: 'always' }, + lint: { someFutureRule: 'warning', unknownProperties: 'ignore' }, + tags: ['styled'], + validate: true, +} +const lintLevel: string = configuration.lint.unknownProperties +const arbitraryLintValue: number = configuration.lint['whatever'] +void lintLevel +void arbitraryLintValue + +class LegacyShapeVirtualDocumentProvider implements api.VirtualDocumentProvider { + public createVirtualDocument(): any { + return undefined + } + + public fromVirtualDocOffset(offset: number): number { + return offset + } + + public fromVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter { + return position + } + + public getVirtualDocumentWrapper(): string { + return '' + } + + public toVirtualDocOffset(offset: number): number { + return offset + } + + public toVirtualDocPosition(position: ts.LineAndCharacter): ts.LineAndCharacter { + return position + } +} +void LegacyShapeVirtualDocumentProvider diff --git a/test/package-api/consumer-api.cts b/test/package-api/consumer-api.cts new file mode 100644 index 0000000..d03a982 --- /dev/null +++ b/test/package-api/consumer-api.cts @@ -0,0 +1,21 @@ +import api = require('@styled/typescript-styled-plugin/api') +import ts = require('typescript/lib/tsserverlibrary.js') + +void api.StyledTemplateLanguageService +void api.PluginConfigurationManager + +declare const virtualDocumentProvider: api.VirtualDocumentProvider + +const configurationManager = new api.PluginConfigurationManager() +const resolvedConfiguration: api.StyledPluginConfiguration = configurationManager.config +const templateSettings = api.getTemplateSettings(configurationManager) +const templateLanguageService = new api.StyledTemplateLanguageService( + ts, + configurationManager, + virtualDocumentProvider, + { log() {} }, +) + +void resolvedConfiguration +void templateSettings +void templateLanguageService diff --git a/test/package-api/consumer-lib-api-default.mts b/test/package-api/consumer-lib-api-default.mts new file mode 100644 index 0000000..90f5abc --- /dev/null +++ b/test/package-api/consumer-lib-api-default.mts @@ -0,0 +1,12 @@ +/** + * A default import of "@styled/typescript-styled-plugin/lib/api.js" resolves to the whole + * CommonJS exports object, matching how 1.0.1 shipped this file as plain CommonJS: unlike "./api" + * (a real ES module with named exports only), "./lib/api" and "./lib/api.js" always resolve as + * CommonJS regardless of import or require syntax. Checked under `moduleResolution: node16` + * (tsconfig.json, tsconfig.consumers.json) and `bundler` (tsconfig.consumers.bundler.json). + */ +import api from '@styled/typescript-styled-plugin/lib/api.js' + +void api.StyledTemplateLanguageService +void api.PluginConfigurationManager +void api.getTemplateSettings diff --git a/test/package-api/consumer-node10.cts b/test/package-api/consumer-node10.cts new file mode 100644 index 0000000..e87f567 --- /dev/null +++ b/test/package-api/consumer-node10.cts @@ -0,0 +1,22 @@ +/** + * A moduleResolution: node10 consumer, matching how a 1.0.1 project (published with no + * "exports" map at all) resolved these paths. node10 resolution never consults "exports" + * (docs/architecture.md, "Public surface"), so every one of these deep imports depends on a + * real file (or a typesVersions redirect) sitting at the literal requested path. + */ +import { StyledTemplateLanguageService as ApiExport } from '@styled/typescript-styled-plugin/api' +import { StyledTemplateLanguageService as LibApi } from '@styled/typescript-styled-plugin/lib/api' +import { StyledTemplateLanguageService as LibApiJs } from '@styled/typescript-styled-plugin/lib/api.js' +import plugin = require('@styled/typescript-styled-plugin/lib/index') +import pluginJs = require('@styled/typescript-styled-plugin/lib/index.js') +import * as ts from 'typescript/lib/tsserverlibrary.js' + +void ApiExport +void LibApi +void LibApiJs + +const pluginModule: ts.server.PluginModule = plugin({ typescript: ts }) +const pluginModuleFromJsSpecifier: ts.server.PluginModule = pluginJs({ typescript: ts }) + +void pluginModule +void pluginModuleFromJsSpecifier diff --git a/test/package-api/consumer-root.ts b/test/package-api/consumer-root.ts new file mode 100644 index 0000000..8b07f40 --- /dev/null +++ b/test/package-api/consumer-root.ts @@ -0,0 +1,6 @@ +import plugin from '@styled/typescript-styled-plugin' +import * as ts from 'typescript/lib/tsserverlibrary.js' + +const pluginModule: ts.server.PluginModule = plugin({ typescript: ts }) + +void pluginModule diff --git a/test/package-api/consumer.cts b/test/package-api/consumer.cts new file mode 100644 index 0000000..6a43bc4 --- /dev/null +++ b/test/package-api/consumer.cts @@ -0,0 +1,6 @@ +import plugin = require('@styled/typescript-styled-plugin') +import ts = require('typescript/lib/tsserverlibrary.js') + +const pluginModule: ts.server.PluginModule = plugin({ typescript: ts }) + +void pluginModule diff --git a/test/package-api/consumer.ts b/test/package-api/consumer.ts new file mode 100644 index 0000000..e60b75d --- /dev/null +++ b/test/package-api/consumer.ts @@ -0,0 +1,88 @@ +import { + ConfigurationManager, + PluginConfigurationManager, + type StyledPluginConfiguration, + type StyledPluginConfigurationInput, + type StyledPluginEmmetConfiguration, + type StyledPluginLintConfiguration, + type StyledPluginLintLevel, + StyledTemplateLanguageService, + type VirtualDocumentProvider, + getTemplateSettings, +} from '@styled/typescript-styled-plugin/api' +import * as ts from 'typescript/lib/tsserverlibrary.js' + +void StyledTemplateLanguageService + +declare const configuration: StyledPluginConfiguration +declare const configurationInput: StyledPluginConfigurationInput +declare const virtualDocumentProvider: VirtualDocumentProvider + +const lintLevel: StyledPluginLintLevel = 'warning' +const lintConfiguration: StyledPluginLintConfiguration = { + unknownAtRules: 'ignore', + unknownProperties: lintLevel, + validProperties: ['--accent-color'], +} +const emmetConfiguration: StyledPluginEmmetConfiguration = { + showAbbreviationSuggestions: true, + showSuggestionsAsSnippets: true, +} +const typedConfiguration: StyledPluginConfiguration = { + emmet: emmetConfiguration, + lint: lintConfiguration, + tags: ['css'], + validate: true, +} +const partialConfiguration: StyledPluginConfigurationInput = { validate: false } + +const invalidLintConfiguration: StyledPluginLintConfiguration = { + // @ts-expect-error lint keys must be supported by the CSS language service. + misspelledRule: 'warning', +} + +const invalidEmmetConfiguration: StyledPluginEmmetConfiguration = { + // @ts-expect-error Emmet configuration keys must be supported by the helper. + showAbbreviationSuggestion: true, +} + +const configurationManager = new PluginConfigurationManager() +/** ConfigurationManager is the 1.0.1-named alias for PluginConfigurationManager. */ +const aliasedConfigurationManager: ConfigurationManager = new ConfigurationManager() +const resolvedConfiguration: StyledPluginConfiguration = configurationManager.config +const resolvedTags: ReadonlyArray = resolvedConfiguration.tags +const resolvedValidate: boolean = resolvedConfiguration.validate +const templateSettings = getTemplateSettings(configurationManager) +const templateLanguageService = new StyledTemplateLanguageService( + ts, + configurationManager, + virtualDocumentProvider, + { log() {} }, +) +const sourcePosition: ts.LineAndCharacter = virtualDocumentProvider.fromVirtualDocPosition({ + line: 1, + character: 0, +}) +const sourceOffset: number = virtualDocumentProvider.fromVirtualDocOffset(7, {} as never) +const legacyCodeFixes: ts.CodeAction[] = templateLanguageService.getCodeFixesAtPosition( + {} as never, + 0, + 0, +) + +void configuration +void configurationInput +void aliasedConfigurationManager +void virtualDocumentProvider +void typedConfiguration +void partialConfiguration +void invalidLintConfiguration +void invalidEmmetConfiguration +void resolvedConfiguration +void resolvedTags +void resolvedValidate +void templateSettings +void templateLanguageService +void sourcePosition +void sourceOffset +void legacyCodeFixes diff --git a/test/package-api/packed-package.cjs b/test/package-api/packed-package.cjs new file mode 100644 index 0000000..d06b07e --- /dev/null +++ b/test/package-api/packed-package.cjs @@ -0,0 +1,46 @@ +/** + * Installs a packed tarball of this package into a throwaway project, the way a consumer's + * install lays it out: the archive extracted to node_modules/@styled/typescript-styled-plugin, + * and each runtime dependency it declares linked from this repository's own node_modules + * (nothing is downloaded). Plain CommonJS with no syntax newer than Node 14.21.3, since + * tsserver-smoke.cjs loads it on the package's own Node floor (docs/tsserver-host.md). + */ +const childProcess = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const PACKAGE_NAME = '@styled/typescript-styled-plugin' + +/** Returns the directory the package was extracted to. */ +function installPackedPackage({ projectDirectory, tarballPath, workspaceRoot }) { + const packageDirectory = path.join(projectDirectory, 'node_modules', PACKAGE_NAME) + fs.mkdirSync(packageDirectory, { recursive: true }) + runChecked('tar', ['-xzf', tarballPath, '--strip-components=1', '-C', packageDirectory]) + + const { dependencies } = JSON.parse( + fs.readFileSync(path.join(packageDirectory, 'package.json'), 'utf8'), + ) + for (const dependency of Object.keys(dependencies)) { + linkWorkspaceModule({ name: dependency, projectDirectory, workspaceRoot }) + } + return packageDirectory +} + +/** Links node_modules/ in the project to the same module in this repository. */ +function linkWorkspaceModule({ name, projectDirectory, workspaceRoot }) { + const link = path.join(projectDirectory, 'node_modules', name) + fs.mkdirSync(path.dirname(link), { recursive: true }) + fs.symlinkSync(path.join(workspaceRoot, 'node_modules', name), link, 'dir') +} + +function runChecked(command, args) { + const result = childProcess.spawnSync(command, args, { stdio: 'inherit' }) + if (result.error) { + throw new Error(`${command} ${args.join(' ')} could not start: ${result.error.message}`) + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with status ${result.status}.`) + } +} + +module.exports = { PACKAGE_NAME, installPackedPackage, linkWorkspaceModule } diff --git a/test/package-api/packed-package.d.cts b/test/package-api/packed-package.d.cts new file mode 100644 index 0000000..eb6a1cf --- /dev/null +++ b/test/package-api/packed-package.d.cts @@ -0,0 +1,15 @@ +/** Types for test/package-api/packed-package.cjs. */ + +export const PACKAGE_NAME: '@styled/typescript-styled-plugin' + +export function installPackedPackage(options: { + projectDirectory: string + tarballPath: string + workspaceRoot: string +}): string + +export function linkWorkspaceModule(options: { + name: string + projectDirectory: string + workspaceRoot: string +}): void diff --git a/test/package-api/runtime-esm-consumer.mjs b/test/package-api/runtime-esm-consumer.mjs new file mode 100644 index 0000000..03da562 --- /dev/null +++ b/test/package-api/runtime-esm-consumer.mjs @@ -0,0 +1,142 @@ +/** + * The import() half of runtime.ts: copied next to the extracted package in its throwaway + * project, so every specifier here resolves through that project's node_modules. + */ +import importedPluginFactory from '@styled/typescript-styled-plugin' +import * as api from '@styled/typescript-styled-plugin/api' +import * as libApi from '@styled/typescript-styled-plugin/lib/api' +import * as libApiJs from '@styled/typescript-styled-plugin/lib/api.js' +import libApiJsDefault from '@styled/typescript-styled-plugin/lib/api.js' +import * as libIndex from '@styled/typescript-styled-plugin/lib/index' +import * as libIndexJs from '@styled/typescript-styled-plugin/lib/index.js' +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { TextDocument } from 'vscode-languageserver-textdocument' + +const API_EXPORTS = [ + 'StyledTemplateLanguageService', + 'PluginConfigurationManager', + 'getTemplateSettings', +] + +function expectFunction(value, description) { + if (typeof value !== 'function') { + throw new TypeError(`${description} must be a function; received ${typeof value}.`) + } +} + +function expectApiExports(module, description) { + for (const name of API_EXPORTS) { + expectFunction(module[name], `${name} from ${description}`) + } +} + +expectFunction(importedPluginFactory, 'The default import of the packed tsserver entry') +expectFunction( + importedPluginFactory({ typescript: ts }).create, + 'create on the plugin module from the default import of the packed tsserver entry', +) +expectApiExports(api, 'import of "@styled/typescript-styled-plugin/api"') +expectFunction(libIndex.default, 'The default export of import("lib/index")') +expectFunction(libIndexJs.default, 'The default export of import("lib/index.js")') +expectApiExports(libApi, 'import of "lib/api"') +expectApiExports(libApiJs, 'import of "lib/api.js"') +/** 1.0.1 shipped lib/api.js as plain CommonJS, so its default import is the whole exports object. */ +expectApiExports(libApiJsDefault, 'the default import of "lib/api.js"') + +const prefix = ':root{\n' +const virtualDocumentProvider = { + createVirtualDocument(context) { + return TextDocument.create(context.fileName, 'scss', 1, prefix + context.text + '\n}') + }, + fromVirtualDocOffset(offset) { + return offset - prefix.length + }, + fromVirtualDocPosition(position) { + return { character: position.character, line: position.line - 1 } + }, + getVirtualDocumentWrapper() { + return prefix + }, + toVirtualDocOffset(offset) { + return offset + prefix.length + }, + toVirtualDocPosition(position) { + return { character: position.character, line: position.line + 1 } + }, +} +const languageServiceFactory = { + createCssLanguageService() { + return { + configure() {}, + doComplete() { + return { isIncomplete: false, items: [] } + }, + } + }, + createScssLanguageService() { + return { + configure() {}, + doCodeActions() { + return [ + { + arguments: [ + undefined, + undefined, + [ + { + newText: 'border', + range: { end: { character: 7, line: 1 }, start: { character: 0, line: 1 } }, + }, + ], + ], + command: '_css.applyCodeAction', + title: "Rename to 'border'", + }, + ] + }, + doComplete() { + return { isIncomplete: false, items: [] } + }, + doHover() { + return null + }, + doValidation() { + return [ + { + message: 'Unknown property', + range: { end: { character: 7, line: 1 }, start: { character: 0, line: 1 } }, + }, + ] + }, + getFoldingRanges() { + return [] + }, + parseStylesheet() { + return {} + }, + } + }, +} +const context = { + fileName: 'consumer.ts', + node: {}, + rawText: 'boarder: red;', + text: 'boarder: red;', + toOffset(position) { + return position.character + }, + toPosition(offset) { + return { character: offset, line: 0 } + }, + typescript: ts, +} +const service = new api.StyledTemplateLanguageService( + ts, + new api.PluginConfigurationManager(), + virtualDocumentProvider, + languageServiceFactory, +) +const fixes = service.getCodeFixesAtPosition(context, 0, 7) +if (fixes[0]?.changes[0]?.textChanges[0]?.newText !== 'border') { + throw new TypeError('The packed API entry must support the legacy three-argument code-fix call.') +} diff --git a/test/package-api/runtime.ts b/test/package-api/runtime.ts new file mode 100644 index 0000000..a2f3fa4 --- /dev/null +++ b/test/package-api/runtime.ts @@ -0,0 +1,124 @@ +/** + * Loads a packed tarball of this package from a throwaway project: the tsserver entry, `./api`, + * and the 1.0.1 deep subpaths through require(), then runtime-esm-consumer.mjs for import(). + * + * Usage: node --experimental-strip-types test/package-api/runtime.ts [tarball] + * The tarball defaults to package-artifact/package.tgz, which `corepack yarn pack:artifact` + * writes after building lib/. + */ +import { execFileSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const workspaceRoot = path.resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +const { + PACKAGE_NAME, + installPackedPackage, + linkWorkspaceModule, +}: typeof import('./packed-package.cjs') = require('./packed-package.cjs') + +const DEFAULT_TARBALL = path.join(workspaceRoot, 'package-artifact', 'package.tgz') +const API_EXPORTS = [ + 'StyledTemplateLanguageService', + 'PluginConfigurationManager', + 'getTemplateSettings', +] + +/** + * Bounds on the import() consumer, which runs one code-fix request through the packed plugin: a + * healthy run takes well under a second and a few dozen megabytes, so a stop means the packed + * plugin loops forever or retains without bound. + */ +const ESM_CONSUMER_DEADLINE_MS = 60_000 +const ESM_CONSUMER_HEAP_CAP_MB = 512 + +const tarballPath = path.resolve(process.argv[2] ?? DEFAULT_TARBALL) +if (!existsSync(tarballPath)) { + console.error( + `No packed tarball at ${tarballPath}. Run "corepack yarn pack:artifact" (it builds lib/ ` + + 'first), or pass a tarball path.', + ) + process.exit(1) +} + +function expectFunction(value: unknown, description: string): void { + if (typeof value !== 'function') { + throw new TypeError(`${description} must be a function; received ${typeof value}.`) + } +} + +function expectApiExports(module: Record, description: string): void { + for (const name of API_EXPORTS) { + expectFunction(module[name], `${name} from ${description}`) + } +} + +const projectDirectory = mkdtempSync(path.join(tmpdir(), 'typescript-styled-plugin-')) +try { + const packageDirectory = installPackedPackage({ projectDirectory, tarballPath, workspaceRoot }) + linkWorkspaceModule({ name: 'typescript', projectDirectory, workspaceRoot }) + + const requireFromConsumer = createRequire(path.join(projectDirectory, 'consumer.cjs')) + const typescript = require('typescript/lib/tsserverlibrary.js') + + const pluginFactory = requireFromConsumer(PACKAGE_NAME) + expectFunction(pluginFactory, `require("${PACKAGE_NAME}")`) + expectFunction( + pluginFactory({ typescript }).create, + `create on the module require("${PACKAGE_NAME}") builds`, + ) + + /** 1.0.1 shipped no "exports" map, so every deep path it had must keep resolving. */ + for (const deepPath of ['lib/index', 'lib/index.js']) { + expectFunction( + requireFromConsumer(`${PACKAGE_NAME}/${deepPath}`), + `require("${PACKAGE_NAME}/${deepPath}")`, + ) + } + for (const deepPath of ['lib/api', 'lib/api.js']) { + expectApiExports( + requireFromConsumer(`${PACKAGE_NAME}/${deepPath}`), + `require("${PACKAGE_NAME}/${deepPath}")`, + ) + } + + /** + * An absolute require() never consults "exports", as a resolver that ignores it (webpack 4, + * Jest before 28) never does, so this passes only when lib/api.js is a real file. + */ + expectApiExports( + requireFromConsumer(path.join(packageDirectory, 'lib', 'api.js')), + 'the physical lib/api.js file', + ) + + /** The "require" condition of "./api" is a real CommonJS build, with no require(esm) needed. */ + expectApiExports(requireFromConsumer(`${PACKAGE_NAME}/api`), `require("${PACKAGE_NAME}/api")`) + + const esmConsumer = path.join(projectDirectory, 'consumer.mjs') + copyFileSync(path.join(import.meta.dirname, 'runtime-esm-consumer.mjs'), esmConsumer) + try { + execFileSync( + process.execPath, + [`--max-old-space-size=${ESM_CONSUMER_HEAP_CAP_MB}`, esmConsumer], + { killSignal: 'SIGKILL', stdio: 'inherit', timeout: ESM_CONSUMER_DEADLINE_MS }, + ) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ETIMEDOUT') { + throw new Error( + `The import() consumer (test/package-api/runtime-esm-consumer.mjs) did not finish within ` + + `${ESM_CONSUMER_DEADLINE_MS} ms and was stopped. It runs one code-fix request through ` + + 'the packed plugin, so the plugin most likely loops forever on it.', + { cause: error }, + ) + } + throw error + } + console.log( + `ok package runtime: ${path.relative(workspaceRoot, tarballPath)} loads through require() and import()`, + ) +} finally { + rmSync(projectDirectory, { force: true, recursive: true }) +} diff --git a/test/package-api/tsconfig.consumers.5.5.json b/test/package-api/tsconfig.consumers.5.5.json new file mode 100644 index 0000000..8658724 --- /dev/null +++ b/test/package-api/tsconfig.consumers.5.5.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.consumers.json", + "compilerOptions": { + "paths": { + "typescript/*": ["../../node_modules/typescript-5.5/*"] + } + } +} diff --git a/test/package-api/tsconfig.consumers.5.9.json b/test/package-api/tsconfig.consumers.5.9.json new file mode 100644 index 0000000..9b15f56 --- /dev/null +++ b/test/package-api/tsconfig.consumers.5.9.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.consumers.json", + "compilerOptions": { + "paths": { + "typescript/*": ["../../node_modules/typescript-5.9/*"] + } + } +} diff --git a/test/package-api/tsconfig.consumers.bundler.json b/test/package-api/tsconfig.consumers.bundler.json new file mode 100644 index 0000000..27a7dd1 --- /dev/null +++ b/test/package-api/tsconfig.consumers.bundler.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "preserve", + "moduleResolution": "bundler", + "noEmit": true, + "paths": { + "typescript/*": ["../../node_modules/typescript-current/*"] + }, + "skipLibCheck": false, + "strict": true, + "types": [] + }, + "include": ["consumer-lib-api-default.mts"] +} diff --git a/test/package-api/tsconfig.consumers.current.json b/test/package-api/tsconfig.consumers.current.json new file mode 100644 index 0000000..04ef993 --- /dev/null +++ b/test/package-api/tsconfig.consumers.current.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.consumers.json", + "compilerOptions": { + "paths": { + "typescript/*": ["../../node_modules/typescript-current/*"] + } + } +} diff --git a/test/package-api/tsconfig.consumers.json b/test/package-api/tsconfig.consumers.json new file mode 100644 index 0000000..fd11f8f --- /dev/null +++ b/test/package-api/tsconfig.consumers.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "skipLibCheck": false, + "strict": true, + "types": [] + }, + "include": [ + "consumer-1.0.1-shape.cts", + "consumer-api.cts", + "consumer-lib-api-default.mts", + "consumer-root.ts", + "consumer.cts", + "consumer.ts" + ] +} diff --git a/test/package-api/tsconfig.consumers.minimum.json b/test/package-api/tsconfig.consumers.minimum.json new file mode 100644 index 0000000..9825184 --- /dev/null +++ b/test/package-api/tsconfig.consumers.minimum.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.consumers.json", + "compilerOptions": { + "paths": { + "typescript/*": ["../../node_modules/typescript-minimum/*"] + } + } +} diff --git a/test/package-api/tsconfig.consumers.node10.json b/test/package-api/tsconfig.consumers.node10.json new file mode 100644 index 0000000..dbb8a2c --- /dev/null +++ b/test/package-api/tsconfig.consumers.node10.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "ignoreDeprecations": "6.0", + "module": "commonjs", + "moduleResolution": "node10", + "noEmit": true, + "paths": { + "typescript/*": ["../../node_modules/typescript-current/*"] + }, + "skipLibCheck": false, + "strict": true, + "types": [] + }, + "include": ["consumer-node10.cts"] +} diff --git a/test/package-api/tsconfig.json b/test/package-api/tsconfig.json new file mode 100644 index 0000000..86e98d4 --- /dev/null +++ b/test/package-api/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "skipLibCheck": false, + "strict": true, + "types": ["node"] + }, + "include": ["runtime.ts"] +} diff --git a/test/package-api/tsserver-smoke.cjs b/test/package-api/tsserver-smoke.cjs new file mode 100644 index 0000000..f3cf34e --- /dev/null +++ b/test/package-api/tsserver-smoke.cjs @@ -0,0 +1,343 @@ +/** + * Drives a real tsserver against the packed tarball to prove the tsserver plugin loads under + * require() and answers completions, diagnostics, and hover for a styled-components template. + * Plain CommonJS so it runs directly on this package's Node floor, 14.21.3 (docs/tsserver-host.md), + * where the vitest-based E2E suite cannot run (vitest declares a newer engines.node itself). + * + * Usage: node tsserver-smoke.cjs [workspace-root] + * + * Set the TSSERVER_SMOKE_MODE environment variable to "inactive" to instead prove graceful + * non-activation on a TypeScript host below the plugin's supported floor: the plugin logs the + * unsupported-version message and returns the host's language service untouched, so tsserver + * keeps answering completions and diagnostics for the same fixture, just without the plugin's + * own contributions. Any other value, or leaving it unset, runs the normal activation check. + */ +const { spawn } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { installPackedPackage } = require('./packed-package.cjs') + +const tarballPath = path.resolve(requireArg(2, 'tarball-path')) +const typescriptDir = path.resolve(requireArg(3, 'typescript-package-dir')) +const workspaceRoot = path.resolve(process.argv[4] || path.join(__dirname, '..', '..')) +const mode = process.env.TSSERVER_SMOKE_MODE === 'inactive' ? 'inactive' : 'active' + +function requireArg(index, name) { + const value = process.argv[index] + if (!value) { + console.error(`Missing required argument <${name}>.`) + console.error( + 'Usage: node tsserver-smoke.cjs [workspace-root]', + ) + process.exit(1) + } + return value +} + +const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tsserver-smoke-')) + +/** + * V8 heap cap for this tsserver, the same value and rationale as + * test/e2e/tsserver-fixture/server.ts's TSSERVER_HEAP_CAP_MB (several times what this fixture's + * server uses, so a plugin regression that retains without bound ends it with "JavaScript heap out + * of memory" instead of growing toward swap); duplicated as a plain number here rather than + * imported, since this script's only build requirement is Node 14.21.3 itself. Verified on a real + * Node 14.21.3 binary that --max-old-space-size caps the old generation there exactly as on later + * Node versions (a run held under the cap completes normally; the same workload comfortably over + * it aborts with a V8 out-of-memory error instead of growing unbounded). + */ +const TSSERVER_HEAP_CAP_MB = 1024 + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) + +async function main() { + try { + installPackedPackage({ projectDirectory: projectDir, tarballPath, workspaceRoot }) + installTypeScript() + writeFixture() + + const { completions, diagnostics, quickinfo, log } = await driveTsserver() + + if (mode === 'inactive') { + assertPluginInactive(log) + assertCompletionsInactive(completions) + assertDiagnosticsInactive(diagnostics) + + console.log( + 'tsserver smoke check passed: plugin logged the unsupported-version message and left tsserver responsive.', + ) + } else { + assertPluginLoaded(log) + assertCompletions(completions) + assertDiagnostics(diagnostics) + assertQuickInfo(quickinfo) + + console.log( + 'tsserver smoke check passed: plugin loaded and answered completions, diagnostics, and hover.', + ) + } + } finally { + fs.rmSync(projectDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +} + +function installTypeScript() { + /** + * A real copy, not a symlink: tsserver resolves a plugin relative to the realpath of the + * typescript package it was loaded from (docs/tsserver-host.md, "classic Node10 rules from + * the host's own directory"), so a symlinked typescript package would send the plugin probe + * to the symlink target's directory tree instead of this fixture project. Copied by hand + * rather than with fs.cpSync, which this script's own Node floor, 14.21.3 (docs/tsserver- + * host.md), does not have (added in Node 16.7.0). + */ + copyDirectory(typescriptDir, path.join(projectDir, 'node_modules', 'typescript')) +} + +function copyDirectory(source, destination) { + fs.mkdirSync(destination, { recursive: true }) + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const sourcePath = path.join(source, entry.name) + const destinationPath = path.join(destination, entry.name) + if (entry.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(sourcePath), destinationPath) + } else if (entry.isDirectory()) { + copyDirectory(sourcePath, destinationPath) + } else { + fs.copyFileSync(sourcePath, destinationPath) + } + } +} + +function writeFixture() { + fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true }) + fs.writeFileSync( + path.join(projectDir, 'src', 'a.ts'), + [ + 'import styled from "styled-components"', + 'export const Button = styled.button`', + ' col', + '`', + 'export const Box = styled.div`', + ' boarder: 1px solid red;', + ' color: blue;', + '`', + '', + ].join('\n'), + ) + fs.writeFileSync( + path.join(projectDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2018', + jsx: 'react', + plugins: [{ name: '@styled/typescript-styled-plugin' }], + }, + include: ['src'], + }), + ) +} + +/** How long to wait for a tsserver response before failing: a dropped response would otherwise hang the process until the CI job's own, much longer, unrelated timeout kills it without naming which request never came back. */ +const PENDING_RESPONSE_TIMEOUT_MS = 10_000 + +function driveTsserver() { + const file = path.join(projectDir, 'src', 'a.ts') + const logFile = path.join(projectDir, 'tsserver-smoke.log') + const tsserverPath = path.join(projectDir, 'node_modules', 'typescript', 'lib', 'tsserver.js') + + return new Promise((resolve, reject) => { + const server = spawn( + process.execPath, + [ + `--max-old-space-size=${TSSERVER_HEAP_CAP_MB}`, + tsserverPath, + '--logVerbosity', + 'verbose', + '--logFile', + logFile, + ], + { cwd: projectDir }, + ) + + let buffer = '' + let sequence = 0 + const pending = new Map() + server.on('error', reject) + /** + * A multibyte UTF-8 character can straddle two "data" chunks; decoding each chunk with its + * own Buffer#toString() call, independent of the bytes before and after it, corrupts a split + * character into the U+FFFD replacement character instead of reassembling it. setEncoding + * uses a StringDecoder internally, which buffers a trailing incomplete sequence until the + * next chunk completes it, so `chunk` below already arrives as a correctly decoded string. + */ + server.stdout.setEncoding('utf8') + server.stdout.on('data', (chunk) => { + buffer += chunk + for (;;) { + const header = /Content-Length: (\d+)\r\n\r\n/.exec(buffer) + if (!header) break + const bodyStart = header.index + header[0].length + const rest = Buffer.from(buffer.slice(bodyStart), 'utf8') + const length = Number(header[1]) + if (rest.length < length) break + const body = rest.subarray(0, length).toString('utf8') + buffer = rest.subarray(length).toString('utf8') + let message + try { + message = JSON.parse(body) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + reject( + new Error(`tsserver sent a message body that is not JSON (${error.message}):\n${body}`), + ) + server.kill() + return + } + if (message.type === 'response' && pending.has(message.request_seq)) { + pending.get(message.request_seq)(message) + pending.delete(message.request_seq) + } + } + }) + + const send = (command, args, expectResponse = true) => + new Promise((resolveResponse, rejectResponse) => { + const requestSeq = ++sequence + if (expectResponse) { + const timeout = setTimeout(() => { + pending.delete(requestSeq) + rejectResponse( + new Error( + `tsserver never responded to "${command}" (request_seq ${requestSeq}) within ${PENDING_RESPONSE_TIMEOUT_MS}ms.`, + ), + ) + }, PENDING_RESPONSE_TIMEOUT_MS) + pending.set(requestSeq, (message) => { + clearTimeout(timeout) + resolveResponse(message) + }) + } + server.stdin.write( + `${JSON.stringify({ seq: requestSeq, type: 'request', command, arguments: args })}\n`, + ) + if (!expectResponse) resolveResponse() + }) + ;(async () => { + try { + await send('open', { file }, false) + const completions = await send('completionInfo', { file, line: 3, offset: 6 }) + const diagnostics = await send('semanticDiagnosticsSync', { file }) + const quickinfo = await send('quickinfo', { file, line: 7, offset: 4 }) + resolve({ completions, diagnostics, quickinfo, log: safeReadLog(logFile) }) + } catch (error) { + reject(error) + } finally { + /** + * Runs on every exit path, including a pending-response timeout: without it, a dropped + * response would leave the tsserver child process running and this script's event loop + * alive indefinitely, hanging instead of failing fast. Only waits for "exit" when the + * process has not already exited on its own (a crash), since that event fires once and + * would otherwise never come for a promise registered after the fact. + */ + if (server.exitCode === null && server.signalCode === null) { + server.stdin.end() + server.kill() + await new Promise((r) => server.once('exit', r)) + } + } + })() + }) +} + +function safeReadLog(logFile) { + try { + return fs.readFileSync(logFile, 'utf8') + } catch { + return '' + } +} + +function assertPluginLoaded(log) { + if (!log.includes('Plugin validation succeeded')) { + throw new Error( + `tsserver never logged "Plugin validation succeeded" for @styled/typescript-styled-plugin.\n${log}`, + ) + } +} + +const UNKNOWN_PROPERTY_MESSAGE = "Unknown property: 'boarder'" + +function completionNames(response) { + return (response.body?.entries || []).map((entry) => entry.name) +} + +function diagnosticMessages(response) { + return (response.body || []).map((diagnostic) => `${diagnostic.code} ${diagnostic.text}`) +} + +function hasUnknownPropertyDiagnostic(response) { + return diagnosticMessages(response).some((message) => message.includes(UNKNOWN_PROPERTY_MESSAGE)) +} + +function assertCompletions(response) { + const names = completionNames(response) + if (!response.success || !names.includes('color')) { + throw new Error( + `Expected a "color" completion inside the styled template, got ${JSON.stringify(names.slice(0, 20))}.`, + ) + } +} + +function assertDiagnostics(response) { + if (!response.success || !hasUnknownPropertyDiagnostic(response)) { + throw new Error( + `Expected an "${UNKNOWN_PROPERTY_MESSAGE}" diagnostic, got ${JSON.stringify(diagnosticMessages(response))}.`, + ) + } +} + +function assertQuickInfo(response) { + const documentation = response.body?.documentation + const text = Array.isArray(documentation) + ? documentation.map((part) => part.text).join('') + : String(documentation || '') + if (!response.success || !text.includes('color')) { + throw new Error(`Expected hover documentation mentioning "color", got ${JSON.stringify(text)}.`) + } +} + +function assertPluginInactive(log) { + if (!/Unsupported TypeScript version .* TypeScript 5\.0 or newer required/.test(log)) { + throw new Error( + `tsserver log never reported the unsupported-version message for @styled/typescript-styled-plugin.\n${log}`, + ) + } +} + +function assertCompletionsInactive(response) { + const names = completionNames(response) + if (!response.success) { + throw new Error(`Expected a successful completions response, got ${JSON.stringify(response)}.`) + } + if (names.includes('color')) { + throw new Error( + `Expected no "color" completion inside the styled template on an inactive host, got ${JSON.stringify(names.slice(0, 20))}.`, + ) + } +} + +function assertDiagnosticsInactive(response) { + if (!response.success) { + throw new Error(`Expected a successful diagnostics response, got ${JSON.stringify(response)}.`) + } + if (hasUnknownPropertyDiagnostic(response)) { + throw new Error( + `Expected no "${UNKNOWN_PROPERTY_MESSAGE}" diagnostic on an inactive host, got ${JSON.stringify(diagnosticMessages(response))}.`, + ) + } +} diff --git a/test/performance/scaling-check-cli.ts b/test/performance/scaling-check-cli.ts new file mode 100644 index 0000000..b864d96 --- /dev/null +++ b/test/performance/scaling-check-cli.ts @@ -0,0 +1,97 @@ +/** + * Pure helpers for test/performance/scaling-check.ts's CLI entry point, kept in their own module so + * they are importable without triggering that file's own `main()` (scaling-check.ts runs its whole + * check suite as a side effect of being the process entry point, which a test importing it directly + * would trigger too). + */ + +const FILTER_FLAG = '--filter' + +/** + * The value after `--filter` in `argv` (already without the `node`/script arguments), or undefined + * when the flag is absent. Throws when the flag is given with no value following it. + */ +export function parseFilterArgument(argv: readonly string[]): string | undefined { + const flagIndex = argv.indexOf(FILTER_FLAG) + if (flagIndex === -1) { + return undefined + } + const value = argv[flagIndex + 1] + if (value === undefined) { + throw new Error( + `${FILTER_FLAG} needs a value: \`corepack yarn test:scaling ${FILTER_FLAG} \`.`, + ) + } + return value +} + +export interface NamedCheck { + readonly name: string +} + +export interface SelectedCheck { + readonly check: Check + /** The check's position in `allChecks`, unchanged by filtering: a worker loads the same full list and looks the check up by this index. */ + readonly index: number +} + +/** + * Every check whose name includes `filter` (a plain substring match, not a pattern), paired with + * its original index into `allChecks`. Every check, in order, when `filter` is undefined. Throws + * loudly when a filter is given but matches nothing, rather than silently running zero checks and + * exiting 0. + */ +export function selectChecks( + allChecks: readonly Check[], + filter: string | undefined, +): readonly SelectedCheck[] { + const indexed = allChecks.map((check, index) => ({ check, index })) + if (filter === undefined) { + return indexed + } + const matched = indexed.filter(({ check }) => check.name.includes(filter)) + if (matched.length === 0) { + throw new Error( + `${FILTER_FLAG} "${filter}" matched no scaling check. Available checks:\n` + + allChecks.map((check) => ` ${check.name}`).join('\n'), + ) + } + return matched +} + +interface StageProgress { + readonly stage: string +} + +interface LineProgress { + readonly line: string +} + +export type StageOrLineProgress = LineProgress | StageProgress + +export interface ProgressLocationTracker { + /** Where the check last reported being, as a trailing clause ("at size 24000, run 2 of 5, attempt 1 of 3", or "before its first sample" when no stage report has arrived yet). */ + location(): string + /** Feed every progress report from a check's worker, in order. */ + onProgress(progress: StageOrLineProgress): void +} + +const NO_SAMPLE_LOCATION = 'before its first sample' + +/** + * Remembers the most recent "stage" progress report across a run, so a later free-form "line" + * report (scaling-check.ts's retry message, printed between timing attempts) does not erase it: a + * retry line only ever appears after real sampling has already happened, so the run's last known + * location should still name that sampling, never claim work never started. + */ +export function createProgressLocationTracker(): ProgressLocationTracker { + let lastStage: string | undefined + return { + location: () => (lastStage === undefined ? NO_SAMPLE_LOCATION : `at ${lastStage}`), + onProgress(progress) { + if ('stage' in progress) { + lastStage = progress.stage + } + }, + } +} diff --git a/test/performance/scaling-check.ts b/test/performance/scaling-check.ts new file mode 100644 index 0000000..35c48b4 --- /dev/null +++ b/test/performance/scaling-check.ts @@ -0,0 +1,1342 @@ +/** + * Scaling guardrail (`corepack yarn test:scaling`, part of `verify` and CI): fails when a hot + * operation's cost grows superlinearly with its input, returns a wrong result for its fixture, + * stops doing work that grows with its input, or when the diagnostics validation cache retains + * past its byte budget. + * + * - Each timed check and the retained-heap guardrail run one at a time, each in its own worker + * thread (scripts/bounded-worker.ts) that the main thread stops at CHECK_DEADLINE_MS of wall + * time or CHECK_HEAP_CAP_MB of heap. Either stop fails the check as over the limit, naming the + * size and attempt it reached. Timings stay CPU time of the worker's own thread. + * - A timed check stops sampling early once its samples already put it far above the threshold + * (EARLY_EXIT_RATIO), and still fails only when every attempt lands above it. + * - Worst-case wall time: RUN_BUDGET_MS, plus module load and one worker shutdown. Nothing outlives + * the run: every worker has exited before the next starts and before the script ends. + * - Exit code 1 on any failure; each FAIL line names the check, and a closing line per failure + * kind says what it most likely means. + * - Needs `--expose-gc` and `--no-flush-bytecode` (both passed by the package script) and the + * development Node version for process.threadCpuUsage; each missing one fails before any check. + * - Blind spots: one input shape per check, so a regression only another shape triggers passes; + * a slowdown by a constant factor passes (the benchmark's job). + */ +import { isMainThread } from 'node:worker_threads' + +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import type * as ts from 'typescript/lib/tsserverlibrary.js' + +import { formatSeconds, runBounded, serveBounded } from '../../scripts/bounded-worker.ts' +import { + MAX_VALIDATION_CACHE_BYTES, + MAX_VALIDATION_CACHE_ENTRIES, +} from '../../src/features/diagnostics.ts' +import { getTemplateSubstitutions } from '../../src/template/template-substitutions.ts' +import { replaceJavaScriptEscapes } from '../../src/virtual-document/javascript-escapes.ts' +import { + createProgressLocationTracker, + parseFilterArgument, + selectChecks, + type StageOrLineProgress, +} from './scaling-check-cli.ts' +import { + createDiagnosticsTemplate, + createFoldingTemplate, + createLargeTemplate, + createTemplateContext, + createTemplateLanguageService, + requireGc, + settleHeap, + type TemplateSpan, +} from './template-language-service-fixture.ts' + +/** + * Guardrail against superlinear regressions in a hot operation. Times each check at N and 4N, + * taking the minimum of RUNS_PER_SIZE runs per size to filter scheduler and GC noise, then fails + * when time(4N)/time(N) exceeds SCALING_THRESHOLD. At 4x the input a linear operation's ratio lands + * near 4 (its fixed per-call cost pulls it lower) and a quadratic one's near 16. Every check's N is + * sized so a quadratic regression injected into its path lands at 10 or more (SUBSTITUTION_CHECK_N + * and the sizes after it), so 7 gives a linear operation's noisy readings 75% headroom above 4 while + * staying clearly under where a real regression lands. + */ +const SCALING_THRESHOLD = 7 +/** + * Every fixture below makes its operation's real work grow with the input, so a correct result + * computed at 4N costs well over the N timing even after the fixed per-call cost pulls the ratio + * under 4. A ratio near 1 means the timed call stopped doing that work (a cache shared across + * service instances, an early return the result guard's expectations happen to accept), so the + * probe measures nothing. Set low enough that scheduler noise inflating the N samples of a light + * check (folding) under heavy machine load stays above it. + */ +const MIN_PLAUSIBLE_RATIO = 1.5 +const RUNS_PER_SIZE = 5 +/** + * How many times a check whose ratio lands above SCALING_THRESHOLD or below MIN_PLAUSIBLE_RATIO is + * measured again before it fails. Heavy machine load inflates one attempt's timings unevenly, + * enough to push a linear operation's ratio past the threshold or a light one's under the floor, + * while a real superlinear regression or a short-circuited probe lands outside the range on every + * attempt, so a check fails only when every attempt does. + */ +const RATIO_RETRY_COUNT = 2 +/** + * An attempt stops sampling and counts as above SCALING_THRESHOLD once, after at least + * EARLY_EXIT_MIN_RUNS runs at each size, its ratio so far (the best 4N sample over the best N + * sample) exceeds this. More samples only lower each best, so the final ratio could still land + * under the threshold, but only if every 4N sample so far ran at over twice its true cost; load + * that inflates CPU time unevenly moves a linear check's ratio from near 4 to about the threshold, + * not to twice it. The check still fails only when every attempt lands above the threshold, so a + * false failure needs that inflation on every attempt. A quadratic regression whose ratio lands + * near 16, or anything steeper, skips the remaining 4N samples, which dominate a regressed check's + * run time. + */ +const EARLY_EXIT_RATIO = 2 * SCALING_THRESHOLD +const EARLY_EXIT_MIN_RUNS = 2 + +/** + * Every check's line prints its wall time; the deadline covers the slowest of those on a loaded + * machine many times over, including all RATIO_RETRY_COUNT retries on a slower CI runner, so only + * a regression that multiplies a check's cost reaches it. The heap cap sits several times above + * what the heaviest check's worker needs, module load included (lower it until checks fail to see + * that need). + */ +const CHECK_DEADLINE_MS = 60_000 +const CHECK_HEAP_CAP_MB = 512 +/** + * The whole run's wall-clock bound: a regression in code every check shares (the boundary + * scanner, the virtual document) can stop every check at its deadline, which would otherwise add + * up past CI's job timeout. Each check's deadline is cut to what remains of this budget, and once + * it is spent the remaining checks fail unrun. A healthy run spends a small fraction of it, so only + * checks stopped at their deadline can spend it. + */ +const RUN_BUDGET_MS = 10 * 60_000 + +/** + * StyledTemplateLanguageService catches every exception a feature throws and returns an empty + * result instead, and an operation that fails fast is also fast at every size, so timing alone + * reads a feature broken on every call as a healthy one. Each check therefore also states what its + * fixture must produce, and every timed result is compared against that after its own timing ends: + * a mismatch fails the script as a wrong result, separately from a ratio failure. + */ +interface ResultMismatch { + readonly expected: string + readonly received: string +} + +interface ScalingCheckDefinition { + readonly n: number + readonly name: string + run(size: number): Result + verify(result: Result, size: number): ResultMismatch | undefined +} + +interface Measurement { + readonly elapsedMs: number + readonly mismatch: ResultMismatch | undefined +} + +interface ScalingCheck { + measure(size: number): Measurement + readonly n: number + readonly name: string +} + +/** + * `over-limit` is a timed check's ratio above SCALING_THRESHOLD, a retained heap over its + * threshold, or a check stopped at its deadline or heap cap; `probe-broken` is a ratio below + * MIN_PLAUSIBLE_RATIO or a heap reading that cannot be real or sits below the floor a working cache + * retains; `crashed` is an exception thrown outside the language service's own recovery. + */ +type CheckOutcome = 'crashed' | 'ok' | 'over-limit' | 'probe-broken' | 'wrong-result' + +/** What a check's worker reports while it runs: a line to print now, or the stage it is entering. */ +type CheckProgress = StageOrLineProgress + +type ReportProgress = (progress: CheckProgress) => void + +interface CheckResult { + /** The first line is the check's summary, printed with the check's wall time. */ + readonly lines: readonly [string, ...string[]] + readonly outcome: CheckOutcome +} + +/** + * Every timing is main-thread CPU time, not wall-clock time. With more runnable threads than cores, + * the scheduler preempts the process every few milliseconds: a short N sample often runs + * uninterrupted while a 4N sample several times longer almost never does, so the minimum over + * several wall-clock samples inflates only the 4N side and pushes a linear operation past + * SCALING_THRESHOLD. Time spent preempted never counts as CPU time. + */ +function elapsedCpuMs(start: NodeJS.CpuUsage): number { + const elapsed = process.threadCpuUsage(start) + return (elapsed.user + elapsed.system) / 1000 +} + +function defineCheck(definition: ScalingCheckDefinition): ScalingCheck { + return { + measure(size) { + const start = process.threadCpuUsage() + const result = definition.run(size) + const elapsedMs = elapsedCpuMs(start) + return { elapsedMs, mismatch: definition.verify(result, size) } + }, + n: definition.n, + name: definition.name, + } +} + +function describeTextAt(text: string, offset: number): string { + return `${text.length} characters, ${JSON.stringify(text.slice(offset, offset + 24))} at offset ${offset}` +} + +function compareText(expected: string, received: string): ResultMismatch | undefined { + if (expected === received) { + return undefined + } + let offset = 0 + while (offset < expected.length && expected[offset] === received[offset]) { + offset++ + } + return { + expected: describeTextAt(expected, offset), + received: describeTextAt(received, offset), + } +} + +function describeOffsets(offsets: ReadonlyArray, index: number): string { + return `${offsets.length} results, result ${index} at offset ${String(offsets[index])}` +} + +function compareOffsets( + expected: readonly number[], + received: ReadonlyArray, +): ResultMismatch | undefined { + let index = 0 + while (index < expected.length && expected[index] === received[index]) { + index++ + } + if (index === expected.length && received.length === expected.length) { + return undefined + } + return { + expected: describeOffsets(expected, index), + received: describeOffsets(received, index), + } +} + +function offsetsOf(text: string, needle: string): number[] { + const offsets: number[] = [] + for ( + let offset = text.indexOf(needle); + offset !== -1; + offset = text.indexOf(needle, offset + 1) + ) { + offsets.push(offset) + } + return offsets +} + +/** Every template in the many-template checks holds one misspelled property, so every request yields exactly one diagnostic. */ +function compareOneDiagnosticEach( + expectedRequestCount: number, + diagnosticsPerRequest: ReadonlyArray, +): ResultMismatch | undefined { + const firstOther = diagnosticsPerRequest.findIndex((diagnostics) => diagnostics.length !== 1) + if (diagnosticsPerRequest.length === expectedRequestCount && firstOther === -1) { + return undefined + } + return { + expected: `${expectedRequestCount} requests, one diagnostic each`, + received: + `${diagnosticsPerRequest.length} requests` + + (firstOther === -1 + ? '' + : `, request ${firstOther} returned ${diagnosticsPerRequest[firstOther]?.length} diagnostics`), + } +} + +/** + * Caches one input per distinct size, so a hot operation's timed run pays only its own cost, never + * the cost of building its fixture: building a fixture (ts.createSourceFile's parse inside + * createTemplateContext, a long string and its span list) is itself linear in size and allocates + * enough to trigger collections, and left inside the timed region it both dilutes a superlinear + * regression enough to land under SCALING_THRESHOLD and adds collection noise to one side of the + * ratio. runTimedCheck's warmup measures every check once at each size before timing it, which + * pays the one build per size; every timed call after that is a cache hit here. Each language + * feature check still constructs a fresh StyledTemplateLanguageService per run() call, which is + * where RawValidationCache and every other per-request cache live (docs/architecture.md, Caching), + * so reusing the same TemplateContext object across calls changes nothing about what the timed + * operation itself has to do. + */ +function cachedBySize(build: (size: number) => Input): (size: number) => Input { + const cache = new Map() + return (size) => { + const cached = cache.get(size) + if (cached !== undefined) { + return cached + } + const built = build(size) + cache.set(size, built) + return built + } +} + +function contextForSize( + buildTemplate: (size: number) => string, +): (size: number) => TemplateContext { + return cachedBySize((size) => createTemplateContext(buildTemplate(size))) +} + +interface TextCase { + readonly expected: string + readonly spans: readonly TemplateSpan[] + readonly text: string +} + +/** The placeholder text every substitution fixture uses; each occurrence is one substitution span. */ +const PLACEHOLDER = '${aaa}' + +interface RepeatedUnit { + readonly count: number + readonly expectedUnit: string + readonly prefix?: string + readonly suffix?: string + readonly unit: string +} + +/** `prefix`, then `count` copies of `unit`, then `suffix`; the expected text repeats `expectedUnit` between the same prefix and suffix, and every PLACEHOLDER in the text becomes a span. */ +function repeatUnit({ + count, + expectedUnit, + prefix = '', + suffix = '', + unit, +}: RepeatedUnit): TextCase { + const text = prefix + unit.repeat(count) + suffix + return { + expected: prefix + expectedUnit.repeat(count) + suffix, + spans: offsetsOf(text, PLACEHOLDER).map((start) => ({ + end: start + PLACEHOLDER.length, + start, + })), + text, + } +} + +/** + * Two block-position placeholders after a declaration on one line (`color: red; ${aaa} ${aaa}`): + * the first follows a ";", the second follows the first (docs/architecture.md, statement rule), so + * both become whitespace, many per line. + */ +function buildStatementPlaceholderCase(count: number): TextCase { + return repeatUnit({ + count, + expectedUnit: `color: red; ${' '.repeat(6)} ${' '.repeat(6)} `, + unit: `color: red; ${PLACEHOLDER} ${PLACEHOLDER} `, + }) +} + +interface TextCheckCase { + readonly build: (count: number) => TextCase + readonly label: string +} + +/** Each case is timed through getTemplateSubstitutions with its text and spans. */ +const substitutionCases: readonly TextCheckCase[] = [ + /** A value placeholder with non-whitespace before it on its line and "px" after it, so it becomes six "x" characters. */ + { + build: (count) => + repeatUnit({ count, expectedUnit: 'margin: xxxxxxpx; ', unit: 'margin: ${aaa}px; ' }), + label: 'single-line placeholders', + }, + { + build: (count) => + repeatUnit({ count, expectedUnit: 'margin: xxxxxxpx;\n', unit: 'margin: ${aaa}px;\n' }), + label: 'multi-line placeholders', + }, + /** + * A placeholder in property-name position, many per line with no "{" anywhere: the shape that + * made getSubstitution's property-name/selector branches rescan toward the far end of the + * document per placeholder. Each becomes the fake property name "$axxxx", since no "--" precedes + * it. + */ + { + build: (count) => repeatUnit({ count, expectedUnit: '$axxxx: 1px; ', unit: '${aaa}: 1px; ' }), + label: 'property-name placeholders, one line', + }, + /** A placeholder in selector position, many per line; each becomes "&" padded with spaces. */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: '& :hover{color:red;} ', + unit: '${aaa}:hover{color:red;} ', + }), + label: 'selector placeholders, one line', + }, + { + build: buildStatementPlaceholderCase, + label: 'block-position placeholders after a statement boundary, one line', + }, + /** The statement rule looks back past each block comment (createCodeLookback), so each placeholder becomes whitespace. */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: `color: red; /* c */ ${' '.repeat(6)} `, + unit: 'color: red; /* c */ ${aaa} ', + }), + label: 'block-position placeholders after a block comment, one line', + }, + /** + * Every placeholder's lookback lands inside the same comment, which must be scanned once, not + * once per placeholder. Each stays an x-filled value. + */ + { + build: (count) => + repeatUnit({ count, expectedUnit: 'xxxxxx ', prefix: '/* ', suffix: '*/', unit: '${aaa} ' }), + label: 'placeholders inside one block comment', + }, + /** + * Every placeholder's name scan reaches the same name end, which must be found once, not once + * per placeholder. Each becomes the Sass interpolation "#{x }". + */ + { + build: (count) => + repeatUnit({ count, expectedUnit: '#{x }', suffix: '-x: 1px;', unit: PLACEHOLDER }), + label: 'adjacent placeholders joined into one property name', + }, + /** Each condition looks back over one word, never to the at-keyword, and becomes "(x )". */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: ' and (x )', + prefix: '@media screen', + suffix: ' {}', + unit: ' and ${aaa}', + }), + label: 'at-rule condition placeholders, one prelude', + }, + /** + * The rule body opens after a line break and a long run of whitespace, which must be scanned + * once, not once per placeholder. Each becomes "&" padded with spaces. + */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: '& :x ', + suffix: `\n${' '.repeat(count)}{}`, + unit: '${aaa}:x ', + }), + label: 'selector placeholders whose rule body opens on the next line', + }, + /** + * Every placeholder's selector-list scan reaches the same "{", which must be found once, not + * once per placeholder. Each becomes "&" padded with spaces. + */ + { + build: (count) => + repeatUnit({ count, expectedUnit: '& :x,\n', suffix: 'a {}', unit: '${aaa}:x,\n' }), + label: 'selector list placeholders, one per line ending in ","', + }, + /** + * Every placeholder on one line reaches the same line break after a long run of whitespace: + * whether that line ends in "," must be read once, not once per placeholder. + */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: '& :x, ', + suffix: `${' '.repeat(count)}\na {}`, + unit: '${aaa}:x, ', + }), + label: 'selector list placeholders on one line ending in "," after long whitespace', + }, + /** + * The selector-shape search walks the text with the boundary scanner, which must resume where + * its last query stopped instead of starting over per placeholder. Each ";" sits inside a comment + * or string, so each placeholder still becomes "&" padded with spaces. + */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: '& :hover /* a; */ {color:red;} ', + unit: '${aaa}:hover /* a; */ {color:red;} ', + }), + label: 'selector placeholders with a comment holding ";" before the body, one line', + }, + { + build: (count) => + repeatUnit({ + count, + expectedUnit: '& :not([x="a;b"]) ', + suffix: `\n${' '.repeat(count)}{}`, + unit: '${aaa}:not([x="a;b"]) ', + }), + label: 'selector placeholders with a string holding ";" whose rule body opens on the next line', + }, + /** The list scan's remembered range must reach past each line's comment to the same "{". */ + { + build: (count) => + repeatUnit({ + count, + expectedUnit: '& :x, /* a; */\n', + suffix: 'a {}', + unit: '${aaa}:x, /* a; */\n', + }), + label: 'selector list placeholders, one per line ending in "," and a comment holding ";"', + }, + /** + * Every statement starts with a block comment no "*\/" ever closes: the at-rule condition test + * skips each statement's leading comments, and the search for the missing close must read the + * rest of the text once, not once per statement. Every placeholder sits inside the comment, so + * each stays an x-filled value. + */ + { + build: (count) => + repeatUnit({ count, expectedUnit: 'xxxxxx;/*', prefix: '/*', unit: '${aaa};/*' }), + label: 'statements each opening an unterminated comment', + }, + /** Out-of-order spans take normalizeSpans' copy-and-sort path, which must stay near-linear. */ + { + build: (count) => { + const textCase = buildStatementPlaceholderCase(count) + return { ...textCase, spans: textCase.spans.toReversed() } + }, + label: 'block-position placeholders, spans given in reverse order', + }, +] + +/** Each case is timed through replaceJavaScriptEscapes with its text; spans are unused. */ +const escapeCases: readonly TextCheckCase[] = [ + /** + * CSS-escaped parens before a hex digit (`a\\(b` in the raw text), so no escape can absorb its + * padding: each looks back for the url() it sits in, which must read the argument once, not once + * per escape. Each padding becomes "_". + */ + { + build: (count) => + repeatUnit({ count, expectedUnit: 'a_\\(b', prefix: 'url(', suffix: ')', unit: 'a\\\\(b' }), + label: 'padded escapes in one url() argument', + }, + /** Each hex escape run follows "/", so every run's padding is "_". */ + { + build: (count) => + repeatUnit({ count, expectedUnit: 'a/A___', prefix: 'url(', suffix: ')', unit: 'a/\\x41' }), + label: 'hex escapes in one url() argument', + }, + /** Each escape run continues the name, so every run's padding joins the spaces in front of it. */ + { + build: (count) => ({ + expected: ' '.repeat(3 * count) + 'ab'.repeat(count), + spans: [], + text: 'a\\x62'.repeat(count), + }), + label: 'escape runs inside one name', + }, +] + +/** + * The per-placeholder algorithmic cost these checks exercise is small enough per character that a + * genuinely quadratic per-placeholder regression in getTemplateSubstitutions needs a larger N + * before its quadratic term clearly dominates the linear one; at a too-small N, the ratio can land + * only marginally above SCALING_THRESHOLD instead of clearly above it. This is the smallest size + * that, verified against a scratch copy of this repository with an injected quadratic per- + * placeholder prefix scan (never committed here), lands every substitution check below at + * ratio >= 10 while an unregressed run stays near 4. + */ +const SUBSTITUTION_CHECK_N = 6_000 + +function defineTextCheck( + name: string, + build: (count: number) => TextCase, + transform: (textCase: TextCase) => string, +): ScalingCheck { + const caseForSize = cachedBySize(build) + return defineCheck({ + n: SUBSTITUTION_CHECK_N, + name, + run: (size) => transform(caseForSize(size)), + verify: (result, size) => compareText(caseForSize(size).expected, result), + }) +} + +/** + * Sized the same way for the language-feature checks below, each against a quadratic injected into + * that feature's own path in a scratch copy: a per-result prefix scan for diagnostics, folding, and + * code fixes (whose candidate-diagnostic filter runs once per diagnostic in the template), and a + * per-line prefix scan for hover and completions, whose result count stays fixed as the template + * grows. At a small N, time that does not grow with the template is a large share of each timing and + * pulls every ratio toward 1, a regressed one included. Each size below is the smallest that lands + * its injected regression at ratio >= 10; folding's per-request work is light enough that this also + * drags its unregressed ratio well under 4 at a smaller size, so its size is set instead by keeping + * that ratio near 4 when it runs after every check above. + */ +const DIAGNOSTICS_TEMPLATE_CHECK_N = 600 +/** + * Sized the same way, against a per-"@" prefix scan injected into the virtual document's `@layer` + * rewrite: validation cost dominates each timing, so the injected quadratic needs more layers than + * the plain diagnostics check before it lands at ratio >= 10. + */ +const LAYER_DIAGNOSTICS_CHECK_N = 2_000 +const FOLDING_CHECK_N = 1_200 +const COMPLETIONS_CHECK_N = 700 +/** + * At 4N the many-template checks cache that many distinct small templates, which must stay under + * MAX_VALIDATION_CACHE_ENTRIES (each entry is far under the byte budget's share) so the repeated + * requests' second pass is all cache hits at both sizes; checked below. + */ +const MANY_TEMPLATE_CHECK_N = 250 +if (MANY_TEMPLATE_CHECK_N * 4 > MAX_VALIDATION_CACHE_ENTRIES) { + throw new Error( + `Scaling check setup: MANY_TEMPLATE_CHECK_N * 4 (${MANY_TEMPLATE_CHECK_N * 4}) exceeds ` + + `MAX_VALIDATION_CACHE_ENTRIES (${MAX_VALIDATION_CACHE_ENTRIES}), so the cache-hit check ` + + 'would evict and measure misses. Lower MANY_TEMPLATE_CHECK_N in test/performance/scaling-check.ts.', + ) +} + +/** + * One misspelled property ("colr") per rule, each after a declaration holding JavaScript escapes, + * which the virtual document replaces with same-length stand-ins before validation. + */ +function createEscapedDiagnosticsTemplate(ruleCount: number): string { + return Array.from( + { length: ruleCount }, + (_, index) => `.rule-${index} { content: \\"\\\\f101\\"; colr: red; }`, + ).join('\n') +} + +/** One misspelled property ("colr") per nested block `@layer`, whose prelude the virtual document rewrites. */ +function createLayerDiagnosticsTemplate(layerCount: number): string { + return Array.from( + { length: layerCount }, + (_, index) => `@layer layer-${index} { colr: red; margin: 0; }`, + ).join('\n') +} + +const MISSPELLED_PROPERTY = 'colr' +const diagnosticsContextForSize = contextForSize(createDiagnosticsTemplate) +const foldingContextForSize = contextForSize(createFoldingTemplate) + +interface DiagnosticsCheckCase { + readonly context: (size: number) => TemplateContext + readonly n: number + readonly name: string +} + +/** Every case's template holds one misspelled property per rule, so each yields a diagnostic at every one. */ +const diagnosticsCases: readonly DiagnosticsCheckCase[] = [ + { + context: diagnosticsContextForSize, + n: DIAGNOSTICS_TEMPLATE_CHECK_N, + name: 'diagnostics (many results in one template)', + }, + { + context: contextForSize(createLayerDiagnosticsTemplate), + n: LAYER_DIAGNOSTICS_CHECK_N, + name: 'diagnostics (many nested block @layer rules in one template)', + }, + { + context: contextForSize(createEscapedDiagnosticsTemplate), + n: DIAGNOSTICS_TEMPLATE_CHECK_N, + name: 'diagnostics (many JavaScript escapes in one template)', + }, +] + +function defineDiagnosticsCheck({ context, n, name }: DiagnosticsCheckCase): ScalingCheck { + return defineCheck({ + n, + name, + run: (size) => createTemplateLanguageService().getSemanticDiagnostics(context(size)), + verify: (diagnostics, size) => + compareOffsets( + offsetsOf(context(size).text, MISSPELLED_PROPERTY), + diagnostics.map((diagnostic) => diagnostic.start), + ), + }) +} + +interface CompletionCheckCase { + /** An entry the completions must leave out, and a tail where the same template must offer it (the positive control for its absence). */ + readonly excluded?: { readonly controlTail: string; readonly entry: string } + readonly expected: string + /** What kind of completion `expected` is, for the failure message. */ + readonly kind: string + readonly name: string + /** The large template's last line; the request goes at its end. */ + readonly tail: string +} + +const completionCases: readonly CompletionCheckCase[] = [ + { + expected: 'red', + kind: 'color value', + name: 'completions on a large template', + tail: 'color:', + }, + { + expected: '@media', + kind: 'nested at-rule', + name: 'completions at an at-keyword on a large template', + tail: '@', + }, + /** + * A value typed after a comment holding a ";", whose Emmet expansion is a declaration + * (`margin: 10px;`): the caret placement search then finds that ";" is not code and walks every + * structural character from the start, its costlier path. The value position must not offer + * that declaration, while statement position in the same template does. + */ + { + excluded: { controlTail: 'm10', entry: 'margin: 10px;' }, + expected: 'auto', + kind: 'margin value', + name: 'completions in value position with an Emmet declaration on a large template', + tail: 'margin: /* ; */ m10', + }, +] + +function completionsAtEnd(context: TemplateContext): ts.CompletionInfo { + return createTemplateLanguageService().getCompletionsAtPosition( + context, + context.toPosition(context.text.length), + ) +} + +function entryNames(completions: ts.CompletionInfo): string[] { + return completions.entries.map((entry) => entry.name) +} + +function defineCompletionCheck({ + excluded, + expected, + kind, + name, + tail, +}: CompletionCheckCase): ScalingCheck { + const templateContext = contextForSize((size) => createLargeTemplate(size, tail)) + const controlOffersExcluded = cachedBySize( + (size) => + excluded === undefined || + entryNames( + completionsAtEnd(createTemplateContext(createLargeTemplate(size, excluded.controlTail))), + ).includes(excluded.entry), + ) + return defineCheck({ + n: COMPLETIONS_CHECK_N, + name, + run: (size) => completionsAtEnd(templateContext(size)), + verify(completions, size) { + const names = entryNames(completions) + const hasControl = controlOffersExcluded(size) + const includesExcluded = excluded !== undefined && names.includes(excluded.entry) + if (hasControl && names.includes(expected) && !includesExcluded) { + return undefined + } + return { + expected: + `a "${expected}" entry among the ${kind} completions` + + (excluded === undefined + ? '' + : ` and no "${excluded.entry}" entry, with "${excluded.entry}" offered after ` + + `${JSON.stringify(excluded.controlTail)} in the same template`), + received: + `${names.length} entries, first ${JSON.stringify(names.slice(0, 5))}` + + (includesExcluded ? `, "${excluded.entry}" included` : '') + + (hasControl || excluded === undefined + ? '' + : `, and no "${excluded.entry}" after ${JSON.stringify(excluded.controlTail)}`), + } + }, + }) +} + +/** Inside the first rule's selector `.rule-0` of a diagnostics template. */ +const HOVER_POSITION: ts.LineAndCharacter = { character: 1, line: 0 } +const HOVERED_SELECTOR = '.rule-0' +/** The rename fix the first misspelled property must offer. */ +const EXPECTED_CODE_FIX_TEXT = 'color' + +function firstMisspelledPropertySpan(size: number): TemplateSpan { + const start = diagnosticsContextForSize(size).text.indexOf(MISSPELLED_PROPERTY) + return { end: start + MISSPELLED_PROPERTY.length, start } +} + +const manyTemplateContextsForSize = cachedBySize((size) => + Array.from({ length: size }, (_, index) => + createTemplateContext(`.rule-${index} { colr: red; margin: 0; }`), + ), +) + +const checks: readonly ScalingCheck[] = [ + ...substitutionCases.map(({ build, label }) => + defineTextCheck(`substitution (${label})`, build, ({ spans, text }) => + getTemplateSubstitutions(text, spans), + ), + ), + ...escapeCases.map(({ build, label }) => + defineTextCheck(`JavaScript escape replacement (${label})`, build, ({ text }) => + replaceJavaScriptEscapes(text), + ), + ), + ...diagnosticsCases.map(defineDiagnosticsCheck), + defineCheck({ + n: FOLDING_CHECK_N, + name: 'folding (many spans)', + run: (size) => createTemplateLanguageService().getOutliningSpans(foldingContextForSize(size)), + verify: (spans, size) => + compareOffsets( + offsetsOf(foldingContextForSize(size).text, '.rule-'), + spans.map((span) => span.textSpan.start), + ), + }), + defineCheck({ + n: DIAGNOSTICS_TEMPLATE_CHECK_N, + name: 'hover', + run: (size) => + createTemplateLanguageService().getQuickInfoAtPosition( + diagnosticsContextForSize(size), + HOVER_POSITION, + ), + /** The hover spans the whole selector and names its class. */ + verify(quickInfo) { + const className = HOVERED_SELECTOR.slice(1) + const documentation = quickInfo?.documentation?.map((part) => part.text).join('') ?? '' + if ( + quickInfo?.textSpan.start === 0 && + quickInfo.textSpan.length === HOVERED_SELECTOR.length && + documentation.includes(className) + ) { + return undefined + } + return { + expected: `a span at offset 0 of length ${HOVERED_SELECTOR.length} documenting "${className}"`, + received: + quickInfo === undefined + ? 'no quick info' + : `a span at offset ${quickInfo.textSpan.start} of length ${quickInfo.textSpan.length} ` + + `documenting ${JSON.stringify(documentation.slice(0, 80))}`, + } + }, + }), + ...completionCases.map(defineCompletionCheck), + defineCheck({ + n: DIAGNOSTICS_TEMPLATE_CHECK_N, + name: 'code fixes', + run(size) { + const { end, start } = firstMisspelledPropertySpan(size) + return createTemplateLanguageService().getCodeFixesAtPosition( + diagnosticsContextForSize(size), + start, + end, + ) + }, + verify(fixes, size) { + const { end, start } = firstMisspelledPropertySpan(size) + const hasExpectedFix = fixes.some((fix) => + fix.changes.some((change) => + change.textChanges.some( + (textChange) => + textChange.newText === EXPECTED_CODE_FIX_TEXT && + textChange.span.start === start && + textChange.span.length === end - start, + ), + ), + ) + if (hasExpectedFix) { + return undefined + } + return { + expected: `a fix replacing offset ${start} length ${end - start} with "${EXPECTED_CODE_FIX_TEXT}"`, + received: `${fixes.length} fixes ${JSON.stringify(fixes.map((fix) => fix.description))}`, + } + }, + }), + defineCheck({ + n: MANY_TEMPLATE_CHECK_N, + name: 'diagnostics in a many-template file', + run(size) { + const service = createTemplateLanguageService() + return manyTemplateContextsForSize(size).map((context) => + service.getSemanticDiagnostics(context), + ) + }, + verify: (results, size) => compareOneDiagnosticEach(size, results), + }), + defineCheck({ + /** + * Every template above is distinct, so RawValidationCache (docs/architecture.md, Caching) + * never sees a hit there: this check re-validates the same `size` templates a second time in + * the same order, all cache hits, so a regression in the hit path itself (for example a + * per-lookup cost that grows with the number of cached entries, rather than the constant-time + * lookup a size-bounded LRU map promises) shows up as superlinear growth here even though the + * cold-path check above stays linear. + */ + n: MANY_TEMPLATE_CHECK_N, + name: 'diagnostics in a many-template file (repeated requests, cache hits)', + run(size) { + const service = createTemplateLanguageService() + const contexts = manyTemplateContextsForSize(size) + const results: ts.Diagnostic[][] = [] + for (const context of contexts) { + results.push(service.getSemanticDiagnostics(context)) + } + for (const context of contexts) { + results.push(service.getSemanticDiagnostics(context)) + } + return results + }, + verify: (results, size) => compareOneDiagnosticEach(size * 2, results), + }), +] + +interface SizeTiming { + bestMs: number + mismatch: ResultMismatch | undefined + readonly size: number +} + +interface AttemptTiming { + readonly at4N: SizeTiming + readonly atN: SizeTiming + /** Runs per size taken; under RUNS_PER_SIZE when the attempt stopped at EARLY_EXIT_RATIO. */ + readonly runs: number +} + +/** + * Alternates N and 4N samples, keeping each size's minimum and first mismatch (verification runs + * inside `measure`, after its clock stops). Alternating spreads a burst of machine load across both + * sides of the ratio; timing every N sample before every 4N sample lets one burst inflate a single + * side. Reports each sample's size and attempt before taking it, so a check stopped at its deadline + * or heap cap names where it was. + */ +function timeInterleaved( + check: ScalingCheck, + attemptLabel: string, + reportProgress: ReportProgress, +): AttemptTiming { + const atN: SizeTiming = { bestMs: Infinity, mismatch: undefined, size: check.n } + const at4N: SizeTiming = { bestMs: Infinity, mismatch: undefined, size: check.n * 4 } + for (let run = 1; run <= RUNS_PER_SIZE; run++) { + for (const timing of [atN, at4N]) { + reportProgress({ + stage: `size ${timing.size}, run ${run} of ${RUNS_PER_SIZE}, ${attemptLabel}`, + }) + const measurement = check.measure(timing.size) + timing.bestMs = Math.min(timing.bestMs, measurement.elapsedMs) + timing.mismatch ??= measurement.mismatch + } + if (run >= EARLY_EXIT_MIN_RUNS && at4N.bestMs > EARLY_EXIT_RATIO * atN.bestMs) { + return { at4N, atN, runs: run } + } + } + return { at4N, atN, runs: RUNS_PER_SIZE } +} + +/** + * Times one check at N and 4N. A ratio outside [MIN_PLAUSIBLE_RATIO, SCALING_THRESHOLD] reports a + * "retry" line and measures again, up to RATIO_RETRY_COUNT times; the check passes on the first + * attempt inside the range and fails only when every attempt lands outside it, as the last + * attempt's side of the range. A wrong result fails at once: it does not depend on timing. + */ +function runTimedCheck(check: ScalingCheck, reportProgress: ReportProgress): CheckResult { + /** Warm JIT compilation and cachedBySize's fixtures at both sizes before any timed run. */ + for (const size of [check.n, check.n * 4]) { + reportProgress({ stage: `size ${size}, warmup` }) + check.measure(size) + } + + for (let attempt = 0; ; attempt++) { + const attemptLabel = `attempt ${attempt + 1} of ${RATIO_RETRY_COUNT + 1}` + const { at4N, atN, runs } = timeInterleaved(check, attemptLabel, reportProgress) + + const wrongResult = [atN, at4N].find((timing) => timing.mismatch !== undefined) + if (wrongResult?.mismatch) { + return { + lines: [ + `FAIL ${check.name}: wrong result at size ${wrongResult.size}, so its timing measures ` + + `nothing: expected ${wrongResult.mismatch.expected}; received ${wrongResult.mismatch.received}`, + ], + outcome: 'wrong-result', + } + } + + const ratio = at4N.bestMs / atN.bestMs + const isSuperlinear = ratio > SCALING_THRESHOLD + const isImplausible = ratio < MIN_PLAUSIBLE_RATIO + const reading = + `${check.name}: N=${check.n} ${atN.bestMs.toFixed(3)}ms, 4N=${at4N.size} ` + + `${at4N.bestMs.toFixed(3)}ms, ratio=${ratio.toFixed(2)} (threshold ${SCALING_THRESHOLD}, ` + + `floor ${MIN_PLAUSIBLE_RATIO})` + + (runs < RUNS_PER_SIZE + ? `, stopped after ${runs} of ${RUNS_PER_SIZE} runs per size above ${EARLY_EXIT_RATIO}` + : '') + const attemptsNote = attempt === 0 ? '' : `, ${attemptLabel}` + + if (!isSuperlinear && !isImplausible) { + return { lines: [`ok ${reading}${attemptsNote}`], outcome: 'ok' } + } + if (attempt === RATIO_RETRY_COUNT) { + return { + lines: [`FAIL ${reading}, outside the range on all ${RATIO_RETRY_COUNT + 1} attempts`], + outcome: isSuperlinear ? 'over-limit' : 'probe-broken', + } + } + reportProgress({ + line: + `retry ${attempt + 1}/${RATIO_RETRY_COUNT} ${reading}: ` + + `${isSuperlinear ? 'above the threshold' : 'below the floor'}, measuring again`, + }) + } +} + +const HEAP_GUARDRAIL_RULE_COUNT = 50 +/** + * An inert trailing comment, not real declarations: real CSS validation cost dominates this + * check's wall-clock time and grows faster than linearly with rule count in the underlying CSS + * language service, while RawValidationCache's own byte estimate (docs/architecture.md, Caching) + * charges only for raw text length, not for what kind of content fills it. A comment this size + * makes each edit's template genuinely multi-kilobyte, and its cache entry large enough to tell a + * count-only cache apart from a byte-bounded one, without paying real-declaration validation cost + * for every one of those bytes on every edit. + */ +const HEAP_GUARDRAIL_PADDING_LENGTH = 20_000 + +/** One misspelled property ("colr") per rule, so every edit's template produces HEAP_GUARDRAIL_RULE_COUNT diagnostics; the leading comment and the trailing padding comment make every edit's raw text distinct, forcing a validation-cache miss the way a real keystroke does. */ +function buildHeapGuardrailTemplate(editIndex: number): string { + let text = `/* edit ${editIndex} */\n` + for (let index = 0; index < HEAP_GUARDRAIL_RULE_COUNT; index++) { + text += `margin: ${index}px;\ncolr: red;\n` + } + text += `/* ${'x'.repeat(HEAP_GUARDRAIL_PADDING_LENGTH)} */\n` + return text +} + +/** + * The most guardrail entries RawValidationCache can hold before MAX_VALIDATION_CACHE_BYTES evicts: + * its size estimate charges at least 2 bytes per template character, so this rounds up an upper + * bound. MAX_VALIDATION_CACHE_ENTRIES is far larger, so the byte budget is the bound under test. + */ +const HEAP_GUARDRAIL_ENTRIES_AT_BUDGET = Math.ceil( + MAX_VALIDATION_CACHE_BYTES / (2 * buildHeapGuardrailTemplate(0).length), +) +/** + * Ten times the entries the byte budget holds. A byte-bounded cache stops growing once the budget + * binds, so its reading stays near the budget; a cache bounded only by entry count keeps every + * edit, each retaining tens of kilobytes of real heap, and at this length reads more than twice + * RETAINED_HEAP_THRESHOLD_BYTES (half this length reads too close to the threshold to fail + * reliably). + */ +const RETAINED_HEAP_EDIT_COUNT = 10 * HEAP_GUARDRAIL_ENTRIES_AT_BUDGET +const RETAINED_HEAP_BUDGET_MULTIPLIER = 3 +/** Slack above the raw cache budget for everything else a request retains (the single-entry virtual-document cache, Map bucket overhead). */ +const RETAINED_HEAP_BASELINE_SLACK_BYTES = 2 * 1024 * 1024 +const RETAINED_HEAP_THRESHOLD_BYTES = + MAX_VALIDATION_CACHE_BYTES * RETAINED_HEAP_BUDGET_MULTIPLIER + RETAINED_HEAP_BASELINE_SLACK_BYTES +/** + * How far the retained delta may drop below zero before the probe itself counts as broken (the + * measured service collected before the "after" reading, or settleHeap not converging). A real + * reading sits near the cache's budget and repeats to within a few tenths of a megabyte across + * runs, so a reading below zero by more than this cannot come from noise. + */ +const RETAINED_HEAP_NEGATIVE_TOLERANCE_BYTES = 1024 * 1024 +/** + * The least a working cache retains, so a cache that keeps nothing (which reads under the + * threshold) fails too. A full cache holds each entry's key as a one-byte-per-character string, + * while the size estimate charges two bytes per character, so its keys alone retain close to half + * the byte budget and its diagnostics add more on top. A service with no cache still retains its + * one virtual document and parsed stylesheet. A third of the budget sits between the two readings, + * with about the same factor of room to each (switch caching off in RawValidationCache.set to see + * the uncached reading). + */ +const RETAINED_HEAP_FLOOR_BYTES = MAX_VALIDATION_CACHE_BYTES / 3 +const HEAP_GUARDRAIL_WARMUP_EDIT_COUNT = 100 +/** Edits between progress reports, so a stopped run names the edit it reached. */ +const HEAP_GUARDRAIL_PROGRESS_INTERVAL = 100 + +function formatMb(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(2)}MB` +} + +const NO_BYTECODE_FLUSHING_FLAG = '--no-flush-bytecode' + +/** + * V8 flushes the bytecode of a function left unexecuted across several full collections, at a point + * that depends on how many collections a run happens to trigger: in a worker, the retained-heap + * reading then lands several megabytes apart (below zero on some runs) with no change in what the + * cache holds. V8 flags apply to the whole process, so the flag on the parent covers every worker. + */ +function requireNoBytecodeFlushing(): void { + if (!process.execArgv.includes(NO_BYTECODE_FLUSHING_FLAG)) { + throw new Error( + `The retained-heap guardrail needs Node's ${NO_BYTECODE_FLUSHING_FLAG} flag, or V8's ` + + 'bytecode flushing moves its reading by megabytes between runs: run ' + + '`corepack yarn test:scaling`, which passes it, instead of invoking this file directly.', + ) + } +} + +/** + * Retained-heap guardrail for RawValidationCache (src/features/diagnostics.ts, docs/ + * architecture.md, Caching): a cache bounded only by entry count, not by estimated size, can + * retain far more than MAX_VALIDATION_CACHE_BYTES once its entries are large (many diagnostics + * against a multi-kilobyte template). Runs RETAINED_HEAP_EDIT_COUNT distinct edits through one + * StyledTemplateLanguageService's getSemanticDiagnostics and fails if the heap retained after gc + * exceeds RETAINED_HEAP_THRESHOLD_BYTES or falls below RETAINED_HEAP_FLOOR_BYTES. The readings are + * the worker's own heap: process.memoryUsage().heapUsed inside a worker thread reports that + * thread's V8 heap, not the parent's. + */ +async function runRetainedHeapGuardrail(reportProgress: ReportProgress): Promise { + const gc = requireGc('test:scaling') + requireNoBytecodeFlushing() + /** + * A throwaway service's edits first, so memory the diagnostics path allocates once on first use + * and keeps for the life of the process (compiled code and its type feedback, lazily built + * lookup data) lands before the "before" reading instead of counting as retention. + */ + reportProgress({ stage: `warmup, ${HEAP_GUARDRAIL_WARMUP_EDIT_COUNT} edits` }) + const warmupService = createTemplateLanguageService() + for (let index = 0; index < HEAP_GUARDRAIL_WARMUP_EDIT_COUNT; index++) { + warmupService.getSemanticDiagnostics( + createTemplateContext(buildHeapGuardrailTemplate(-1 - index)), + ) + } + + const service = createTemplateLanguageService() + const before = await settleHeap(gc) + + for (let index = 0; index < RETAINED_HEAP_EDIT_COUNT - 1; index++) { + if (index % HEAP_GUARDRAIL_PROGRESS_INTERVAL === 0) { + reportProgress({ stage: `edit ${index + 1} of ${RETAINED_HEAP_EDIT_COUNT}` }) + } + service.getSemanticDiagnostics(createTemplateContext(buildHeapGuardrailTemplate(index))) + } + const finalContext = createTemplateContext( + buildHeapGuardrailTemplate(RETAINED_HEAP_EDIT_COUNT - 1), + ) + service.getSemanticDiagnostics(finalContext) + + const after = await settleHeap(gc) + const retainedBytes = after - before + + /** + * Reads from `service` again after the "after" gc, so it (and everything it retains, including + * RawValidationCache) cannot be considered unreachable before that measurement runs: a local + * variable's last use inside the edit loop above would otherwise let the interpreter end its + * live range there, making it collectible by the very gc() call meant to measure what it + * retains. This re-read is a cache hit on the last edit's own context, so it changes nothing + * about what was measured; it only keeps `service` reachable through the measurement and proves + * it still answers correctly afterward. + */ + if (service.getSemanticDiagnostics(finalContext).length === 0) { + return { + lines: [ + 'FAIL retained-heap guardrail: the measured service stopped returning diagnostics for its ' + + 'own last edit, so the probe is broken rather than measuring a real cache.', + ], + outcome: 'probe-broken', + } + } + + if (retainedBytes < -RETAINED_HEAP_NEGATIVE_TOLERANCE_BYTES) { + return { + lines: [ + `FAIL retained-heap guardrail: heap usage dropped by ${formatMb(-retainedBytes)} after ` + + `${RETAINED_HEAP_EDIT_COUNT} distinct edits, more than the ` + + `${formatMb(RETAINED_HEAP_NEGATIVE_TOLERANCE_BYTES)} tolerance allows. A real cache ` + + 'addition cannot free memory the "before" reading did not have; the probe is broken (the ' + + 'measured service was collected before the "after" reading, or settleHeap has not ' + + 'converged) rather than evidence the cache shrank.', + ], + outcome: 'probe-broken', + } + } + + const isOverLimit = retainedBytes > RETAINED_HEAP_THRESHOLD_BYTES + const isUnderFloor = retainedBytes < RETAINED_HEAP_FLOOR_BYTES + const reading = + `${isOverLimit || isUnderFloor ? 'FAIL' : 'ok '} retained heap after ` + + `${RETAINED_HEAP_EDIT_COUNT} distinct edits: ${formatMb(retainedBytes)} (floor ` + + `${formatMb(RETAINED_HEAP_FLOOR_BYTES)}, threshold ${formatMb(RETAINED_HEAP_THRESHOLD_BYTES)}, ` + + `negative tolerance ${formatMb(RETAINED_HEAP_NEGATIVE_TOLERANCE_BYTES)})` + if (isUnderFloor) { + return { + lines: [ + reading, + `FAIL retained-heap guardrail: ${formatMb(retainedBytes)} is below the ` + + `${formatMb(RETAINED_HEAP_FLOOR_BYTES)} a working validation cache retains, so the cache ` + + 'keeps no entries (RawValidationCache.set in src/features/diagnostics.ts stores nothing, ' + + 'or the diagnostics path no longer reaches it) or the probe measured nothing.', + ], + outcome: 'probe-broken', + } + } + return { lines: [reading], outcome: isOverLimit ? 'over-limit' : 'ok' } +} + +interface BoundedCheck { + readonly name: string + run(reportProgress: ReportProgress): CheckResult | Promise +} + +const boundedChecks: readonly BoundedCheck[] = [ + ...checks.map((check): BoundedCheck => ({ + name: check.name, + run: (reportProgress) => runTimedCheck(check, reportProgress), + })), + { name: 'retained-heap guardrail', run: runRetainedHeapGuardrail }, +] + +function describeError(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error) +} + +function describeDeadline(deadlineMs: number): string { + return deadlineMs < CHECK_DEADLINE_MS + ? `the ${formatSeconds(deadlineMs)} left of the run's ${formatSeconds(RUN_BUDGET_MS)} budget` + : `its ${formatSeconds(deadlineMs)} wall-clock deadline` +} + +/** Runs one check in its own worker and prints its lines as they arrive. */ +async function runCheckInWorker( + entry: string, + index: number, + deadlineMs: number, +): Promise { + const check = boundedChecks[index] + if (check === undefined) { + throw new Error(`Scaling check setup: no check at index ${index}.`) + } + const location = createProgressLocationTracker() + const outcome = await runBounded({ + entry, + input: index, + limits: { deadlineMs, heapMb: CHECK_HEAP_CAP_MB }, + onProgress(progress) { + if ('line' in progress) { + console.log(progress.line) + } + location.onProgress(progress) + }, + }) + const wall = formatSeconds(outcome.elapsedMs) + const where = location.location() + + switch (outcome.kind) { + case 'done': { + const [summary, ...details] = outcome.result.lines + console.log(`${summary}, ${wall} wall`) + for (const line of details) { + console.log(line) + } + return outcome.result.outcome + } + case 'deadline': + console.log( + `FAIL ${check.name}: stopped at ${describeDeadline(deadlineMs)} ${where}. Its ` + + 'unregressed run takes a small fraction of that even on a loaded machine, so this most ' + + 'likely means a superlinear regression (a quadratic or worse cost, ' + + 'or a loop that never ends) in the operation this check times. See docs/architecture.md ' + + '(substitution and caching invariants).', + ) + return 'over-limit' + case 'heap': + console.log( + `FAIL ${check.name}: reached its ${CHECK_HEAP_CAP_MB}MB heap cap ${where}, after ${wall}. ` + + 'Its unregressed run uses a small fraction of that, so this most likely means unbounded ' + + 'retention (a cache or list that never evicts) or an allocation that grows superlinearly ' + + 'with the input in the operation this check exercises. See docs/architecture.md ' + + '(substitution and caching invariants).', + ) + return 'over-limit' + case 'crashed': + console.log( + `FAIL ${check.name}: threw ${where}, after ${wall}: ${describeError(outcome.error)}`, + ) + return 'crashed' + } +} + +async function main() { + requireGc('test:scaling') + requireNoBytecodeFlushing() + if (typeof process.threadCpuUsage !== 'function') { + throw new Error( + `The scaling check times with process.threadCpuUsage, which Node ${process.version} lacks: ` + + 'run it on the development Node version in .github/.node-version.', + ) + } + const entry = process.argv[1] + if (entry === undefined) { + throw new Error( + 'The scaling check runs each check in a worker loaded from its own file, found through ' + + 'process.argv[1], which is empty: run it as `corepack yarn test:scaling`.', + ) + } + const filter = parseFilterArgument(process.argv.slice(2)) + const selected = selectChecks(boundedChecks, filter) + + const startedAt = performance.now() + const outcomes: CheckOutcome[] = [] + for (const { check, index } of selected) { + const remainingMs = RUN_BUDGET_MS - (performance.now() - startedAt) + if (remainingMs <= 0) { + console.log( + `FAIL ${check.name}: not run, because the checks stopped above spent the run's whole ` + + `${formatSeconds(RUN_BUDGET_MS)} budget. Fix those first.`, + ) + outcomes.push('over-limit') + continue + } + const outcome = await runCheckInWorker(entry, index, Math.min(CHECK_DEADLINE_MS, remainingMs)) + outcomes.push(outcome) + } + + if (outcomes.includes('wrong-result')) { + console.error( + 'Scaling check failed: at least one hot operation returned a wrong result for its fixture ' + + '(a feature that throws is caught and returns an empty result, which is also fast at every ' + + 'size). Fix the feature before reading any ratio; the FAIL line above names the check and ' + + 'the expected result.', + ) + } + if (outcomes.includes('probe-broken')) { + console.error( + 'Scaling check failed: at least one probe measured nothing, so its reading cannot be ' + + `trusted. Either a hot operation took less than ${MIN_PLAUSIBLE_RATIO}x as long at 4N as at ` + + `N on all ${RATIO_RETRY_COUNT + 1} attempts (the timed call is not doing work that grows ` + + 'with its input: a cache shared across service instances, or an early return; find what ' + + 'short-circuits it), or the retained-heap guardrail produced no valid reading or read ' + + 'below its floor (a validation cache that keeps nothing). The FAIL line above names which.', + ) + } + if (outcomes.includes('over-limit')) { + console.error( + 'Scaling check failed: at least one hot operation grew superlinearly with its input size ' + + `on all ${RATIO_RETRY_COUNT + 1} attempts or was stopped at its deadline or heap cap, or ` + + 'the diagnostics validation cache retained more than its byte budget. See ' + + 'docs/architecture.md (substitution and caching invariants).', + ) + } + if (outcomes.includes('crashed')) { + console.error( + 'Scaling check failed: at least one check threw outside the language service, which ' + + 'recovers from its own exceptions. The FAIL line above names the check and holds the stack.', + ) + } + if (outcomes.some((outcome) => outcome !== 'ok')) { + process.exitCode = 1 + } +} + +if (isMainThread) { + void main() +} else { + serveBounded((index, reportProgress) => { + const check = boundedChecks[index] + if (check === undefined) { + throw new Error(`Scaling check worker: no check at index ${index}.`) + } + return check.run(reportProgress) + }) +} diff --git a/test/performance/template-language-service-fixture.ts b/test/performance/template-language-service-fixture.ts new file mode 100644 index 0000000..18c23d7 --- /dev/null +++ b/test/performance/template-language-service-fixture.ts @@ -0,0 +1,171 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import * as ts from 'typescript/lib/tsserverlibrary.js' + +import { PluginConfigurationManager } from '../../src/configuration/plugin-configuration.ts' +import { StyledTemplateLanguageService } from '../../src/template-language-service.ts' +import { getTemplateSubstitutions } from '../../src/template/template-substitutions.ts' +import { StyledVirtualDocumentProvider } from '../../src/virtual-document/styled-virtual-document-provider.ts' + +/** A half-open range of template offsets, the shape the decorator's placeholder spans take. */ +export interface TemplateSpan { + readonly end: number + readonly start: number +} + +export function createTemplateLanguageService(): StyledTemplateLanguageService { + return new StyledTemplateLanguageService( + ts, + new PluginConfigurationManager(), + new StyledVirtualDocumentProvider(ts), + ) +} + +/** + * Mirrors StandardTemplateContext (typescript-template-language-service-decorator's + * standard-template-source-helper.js): toPosition/toOffset resolve through the source file's own + * line map (computed once, then binary-searched per call by TypeScript) rather than re-splitting + * the template text on every call, so the benchmark measures the same cost shape production pays. + * `substitutionSpans` mirrors the decorator's own PlaceholderSubstituter.getPlaceholderSpans + * output (each span covers a whole `${...}` placeholder, delimiters included: + * node_modules/typescript-template-language-service-decorator/lib/standard-template-source- + * helper.js): when given, `context.text` is the real, length-preserving substitution + * (getTemplateSubstitutions, src/template/template-substitutions.ts) production computes for + * every request, not `rawText` with the placeholder syntax left in place, so a benchmark over an + * interpolated template measures the same substituted text tsserver actually translates. + * + * `text` is a lazy getter, computed on first read and memoized only for the one context object it + * belongs to, the same contract StandardTemplateContext gives it: reading `rawText` alone, or never + * reading either, costs nothing beyond the parse below, which every context pays regardless (it + * builds `node`, `toPosition`, and `toOffset`). Splitting `createTemplateContextFactory` out from + * `createTemplateContext` lets a caller that needs many independently-memoized contexts over the + * same template (template-language-service.bench.ts, one context per iteration, mirroring a fresh + * request per keystroke) pay that one-time parse once, not once per context. + */ +export function createTemplateContextFactory( + rawText: string, + substitutionSpans: readonly TemplateSpan[] = [], +): () => TemplateContext { + const sourceFile = ts.createSourceFile( + 'performance-fixture.ts', + `const styles = css\`${rawText}\`;`, + ts.ScriptTarget.Latest, + true, + ) + const statement = sourceFile.statements[0] + if (!statement || !ts.isVariableStatement(statement)) { + throw new Error('Expected a variable statement.') + } + const initializer = statement.declarationList.declarations[0]?.initializer + if (!initializer || !ts.isTaggedTemplateExpression(initializer)) { + throw new Error('Expected a tagged template expression.') + } + const bodyStart = initializer.template.getStart(sourceFile) + 1 + const bodyStartPosition = sourceFile.getLineAndCharacterOfPosition(bodyStart) + + const toPosition: TemplateContext['toPosition'] = (offset) => { + const position = sourceFile.getLineAndCharacterOfPosition(bodyStart + offset) + return { + line: position.line - bodyStartPosition.line, + character: + position.line === bodyStartPosition.line + ? position.character - bodyStartPosition.character + : position.character, + } + } + const toOffset: TemplateContext['toOffset'] = (position) => { + const line = bodyStartPosition.line + position.line + return ( + sourceFile.getPositionOfLineAndCharacter( + line, + position.line === 0 ? bodyStartPosition.character + position.character : position.character, + ) - bodyStart + ) + } + + return () => { + let text: string | undefined + return { + typescript: ts, + fileName: sourceFile.fileName, + node: initializer.template, + rawText, + get text() { + if (text === undefined) { + text = + substitutionSpans.length > 0 + ? getTemplateSubstitutions(rawText, substitutionSpans) + : rawText + } + return text + }, + toOffset, + toPosition, + } + } +} + +export function createTemplateContext( + rawText: string, + substitutionSpans: readonly TemplateSpan[] = [], +): TemplateContext { + return createTemplateContextFactory(rawText, substitutionSpans)() +} + +/** One misspelled property ("colr") per rule, so this yields exactly `ruleCount` diagnostics. */ +export function createDiagnosticsTemplate(ruleCount: number): string { + return Array.from( + { length: ruleCount }, + (_, index) => `.rule-${index} { colr: red; margin: 0; }`, + ).join('\n') +} + +/** One rule per fold, split across lines so each rule body is a distinct folding range. */ +export function createFoldingTemplate(ruleCount: number): string { + return Array.from( + { length: ruleCount }, + (_, index) => `.rule-${index} {\n color: red;\n margin: 0;\n}`, + ).join('\n') +} + +/** `ruleCount` valid rules, then `tail` on its own last line, where a completion request goes. */ +export function createLargeTemplate(ruleCount: number, tail = 'color:'): string { + return ( + Array.from( + { length: ruleCount }, + (_, index) => `.rule-${index} { color: red; margin: 0; padding: 0; }`, + ).join('\n') + `\n${tail}` + ) +} + +/** + * Returns global gc, or fails loudly: without `--expose-gc` a heap reading around a skipped + * collection measures garbage, not retention. `scriptName` is the package script that passes the + * flag. + */ +export function requireGc(scriptName: string): () => void { + const { gc } = globalThis + if (!gc) { + throw new Error( + `Heap measurement needs global gc, which only Node's --expose-gc flag provides: run ` + + `\`corepack yarn ${scriptName}\`, which passes it, instead of invoking this file directly.`, + ) + } + return gc +} + +/** + * Returns heapUsed once it stops shrinking between collections (or after 10 collections). Yields + * one macrotask first, so FinalizationRegistry callbacks and other queued work that would free + * memory run before the first collection instead of reading as retained. + */ +export async function settleHeap(gc: () => void): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) + let previous = Infinity + let current = process.memoryUsage().heapUsed + for (let i = 0; i < 10 && current < previous - 16 * 1024; i++) { + gc() + previous = current + current = process.memoryUsage().heapUsed + } + return current +} diff --git a/test/performance/template-language-service.bench.ts b/test/performance/template-language-service.bench.ts new file mode 100644 index 0000000..2779aa0 --- /dev/null +++ b/test/performance/template-language-service.bench.ts @@ -0,0 +1,422 @@ +import { isMainThread } from 'node:worker_threads' + +import { Bench } from 'tinybench' + +import { formatSeconds, runBounded, serveBounded } from '../../scripts/bounded-worker.ts' +import { + MAX_VALIDATION_CACHE_BYTES, + MAX_VALIDATION_CACHE_ENTRIES, + VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES, +} from '../../src/features/diagnostics.ts' +import { + createDiagnosticsTemplate, + createFoldingTemplate, + createLargeTemplate, + createTemplateContext, + createTemplateContextFactory, + createTemplateLanguageService, + requireGc, + settleHeap, + type TemplateSpan, +} from './template-language-service-fixture.ts' + +/** + * Builds a template whose interpolation placeholders are literal `${...}` text, each span covering + * the whole placeholder, delimiters included, as the decorator's own placeholder spans do + * (createTemplateContext): the substituted text then matches what production translates. + */ +function createInterpolatedTemplate(interpolationCount: number): { + rawText: string + substitutionSpans: TemplateSpan[] +} { + const substitutionSpans: TemplateSpan[] = [] + let rawText = '' + const appendPlaceholder = (expression: string) => { + const start = rawText.length + rawText += `\${${expression}}` + substitutionSpans.push({ end: rawText.length, start }) + } + for (let index = 0; index < interpolationCount; index++) { + rawText += `.rule-${index} { color: ` + appendPlaceholder(`value${index}`) + rawText += '; margin: ' + appendPlaceholder(`space${index}`) + rawText += '; }\n' + } + rawText += 'color:' + return { rawText, substitutionSpans } +} + +/** Inside the first rule's selector. */ +const HOVER_POSITION = { character: 1, line: 0 } + +/** How many distinct templates the "all cache hits" diagnostics task holds warm at once. */ +const MANY_TEMPLATE_COUNT = 20 + +/** One misspelled property ("colr") per template, each its own file's worth of raw text, distinct from every other so each is its own validation cache entry (src/features/diagnostics.ts, RawValidationCache). */ +function createManyTemplateContexts(count: number) { + return Array.from({ length: count }, (_, index) => + createTemplateContext(`.rule-${index} { colr: red; margin: 0; }`), + ) +} + +/** + * Builds every fixture and task. Runs only inside the benchmark's worker: building the cached + * completion fixture already runs plugin code, which the main thread never does. + */ +function createBenchmark(reportProgress: (stage: string) => void): Bench { + const largeTemplate = createLargeTemplate(400) + const largeTemplateContextFactory = createTemplateContextFactory(largeTemplate) + const largeTemplatePosition = largeTemplateContextFactory().toPosition(largeTemplate.length) + + const interpolatedTemplate = createInterpolatedTemplate(120) + const interpolatedContextFactory = createTemplateContextFactory( + interpolatedTemplate.rawText, + interpolatedTemplate.substitutionSpans, + ) + const interpolatedPosition = interpolatedContextFactory().toPosition( + interpolatedTemplate.rawText.length, + ) + + const cachedContext = createTemplateContext(largeTemplate) + const cachedService = createTemplateLanguageService() + const cachedPosition = cachedContext.toPosition(largeTemplate.length) + + reportProgress('building the cached completion fixture') + cachedService.getCompletionsAtPosition(cachedContext, cachedPosition) + + /** + * Small and large sizes for diagnostics/hover/folding: every rule has a misspelled property + * ("colr"), so diagnostic count and folding-range count scale with rule count, exercising + * translation across many results per request rather than one (the shape + * fromVirtualDocPosition's per-call line-start recomputation made quadratic in template size + * times result count; docs/architecture.md, "Virtual document"). + */ + const smallDiagnosticsContextFactory = createTemplateContextFactory(createDiagnosticsTemplate(40)) + const largeDiagnosticsContextFactory = createTemplateContextFactory( + createDiagnosticsTemplate(1_600), + ) + const smallFoldingContextFactory = createTemplateContextFactory(createFoldingTemplate(40)) + const largeFoldingContextFactory = createTemplateContextFactory(createFoldingTemplate(1_600)) + + const manyTemplateContexts = createManyTemplateContexts(MANY_TEMPLATE_COUNT) + const manyTemplateService = createTemplateLanguageService() + reportProgress('warming the many-template diagnostics fixture') + for (const context of manyTemplateContexts) { + manyTemplateService.getSemanticDiagnostics(context) + } + + /** `throws` rethrows a task's exception from run() instead of recording it and printing no row. */ + const benchmark = new Bench({ + iterations: 20, + throws: true, + time: 1_000, + warmupIterations: 5, + warmupTime: 250, + }) + + /** + * Every task below reuses a parsed fixture (createTemplateContextFactory's one-time + * ts.createSourceFile parse), so that parse never lands inside the timed callback, but calls the + * factory again on every iteration for a fresh `TemplateContext` object with its own unmemoized + * `text` getter: the decorator gives every request its own context (StandardTemplateContext) even + * when nothing about the file changed, so a context reused across iterations would hide the cost + * of computing `text` (a substitution over the raw template, src/template/template-substitutions.ts) + * behind the first iteration's memoization, never charging it again. A fresh + * `StyledTemplateLanguageService` (an empty completion/diagnostics cache) is also built per + * iteration, isolating the plugin's own computation from TypeScript's unrelated parse cost. + * 'reuses completions at the same position' and the MANY_TEMPLATE_COUNT-templates task below are + * the deliberate exceptions: both measure a genuinely reused context and service, the warm-cache + * path a real edit-then-request-again sequence takes. + */ + benchmark + .add('completes a large template', () => { + const service = createTemplateLanguageService() + service.getCompletionsAtPosition(largeTemplateContextFactory(), largeTemplatePosition) + }) + .add('completes a template with many interpolations', () => { + const service = createTemplateLanguageService() + service.getCompletionsAtPosition(interpolatedContextFactory(), interpolatedPosition) + }) + .add('reuses completions at the same position', () => { + cachedService.getCompletionsAtPosition(cachedContext, cachedPosition) + }) + .add('reports diagnostics for a small template', () => { + const service = createTemplateLanguageService() + service.getSemanticDiagnostics(smallDiagnosticsContextFactory()) + }) + .add('reports diagnostics for a large template', () => { + const service = createTemplateLanguageService() + service.getSemanticDiagnostics(largeDiagnosticsContextFactory()) + }) + .add('hovers in a small template', () => { + const service = createTemplateLanguageService() + service.getQuickInfoAtPosition(smallDiagnosticsContextFactory(), HOVER_POSITION) + }) + .add('hovers in a large template', () => { + const service = createTemplateLanguageService() + service.getQuickInfoAtPosition(largeDiagnosticsContextFactory(), HOVER_POSITION) + }) + .add('folds a small template', () => { + const service = createTemplateLanguageService() + service.getOutliningSpans(smallFoldingContextFactory()) + }) + .add('folds a large template', () => { + const service = createTemplateLanguageService() + service.getOutliningSpans(largeFoldingContextFactory()) + }) + .add(`${MANY_TEMPLATE_COUNT} templates, all cache hits`, () => { + for (const context of manyTemplateContexts) { + manyTemplateService.getSemanticDiagnostics(context) + } + }) + + for (const task of benchmark.tasks) { + task.addEventListener('warmup', () => reportProgress(`task "${task.name}"`)) + } + return benchmark +} + +const productionSourcePrefixLength = 20_000 +const PRODUCTION_SHAPE_RULE_COUNT = 20 + +function createProductionShapeTemplate(index: number): string { + return createDiagnosticsTemplate(PRODUCTION_SHAPE_RULE_COUNT) + `\n/* ${index} */` +} + +/** + * A fresh 20 KB source string per iteration (`sourcePrefix + template`), sliced down to just the + * template afterward: `rawText` is then a genuine slice of a larger string, as + * typescript-template-language-service-decorator produces it from a real open file + * (StandardTemplateContext), not a freshly concatenated string with no parent to leak. One shared + * service, not a fresh one per iteration, so its per-service caches (the validation cache, + * src/features/diagnostics.ts) behave as they do across many requests against one open file: + * fills, since every iteration's comment makes the template text distinct, then evicts on every + * iteration once its byte budget binds. + */ +function createProductionShapeIteration(): (index: number) => void { + const productionService = createTemplateLanguageService() + return (index) => { + const source = 'x'.repeat(productionSourcePrefixLength) + createProductionShapeTemplate(index) + const rawText = source.slice(productionSourcePrefixLength) + const context = createTemplateContext(rawText) + productionService.getCompletionsAtPosition(context, context.toPosition(rawText.length)) + productionService.getSemanticDiagnostics(context) + productionService.getOutliningSpans(context) + } +} + +/** + * An upper bound on how many production-shape entries the validation cache holds before it first + * evicts: each entry's size estimate charges at least 2 bytes per template character plus + * VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES per diagnostic (one per rule), and the real estimate + * adds the wrapper and each message on top, so the byte budget binds somewhat earlier than this. + */ +const PRODUCTION_SHAPE_FILL_BOUND = Math.min( + MAX_VALIDATION_CACHE_ENTRIES, + Math.floor( + MAX_VALIDATION_CACHE_BYTES / + (2 * createProductionShapeTemplate(0).length + + PRODUCTION_SHAPE_RULE_COUNT * VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES), + ), +) +/** + * h0 lands early in the fill, and h1 at PRODUCTION_SHAPE_FILL_BOUND, past the point where the byte + * budget first evicts. slope1 (h0 to h1) is therefore mostly fill, each iteration adding one net + * retained cache entry: the shape a per-entry retention regression (an unflattened cache key + * holding this iteration's 20 KB parent string, instead of the template it needs) shows up in most + * directly, with a short stretch of eviction at its end. slope2 (h1 to h2) runs entirely at + * capacity, evicting one entry per insertion: it reads near zero when eviction releases what it + * replaces, and stays elevated if the bound stopped enforcing itself or an evicted entry's memory + * does not fully release. + */ +const PRODUCTION_SHAPE_FIRST_BATCH = 100 +const PRODUCTION_SHAPE_BATCH_SIZES: readonly [number, number, number] = [ + PRODUCTION_SHAPE_FIRST_BATCH, + PRODUCTION_SHAPE_FILL_BOUND - PRODUCTION_SHAPE_FIRST_BATCH, + PRODUCTION_SHAPE_FILL_BOUND, +] + +/** + * The leak control's slope must clear the no-op control's by at least this factor for the + * instrument to trust its own numbers: a bare `leakControl.slope2 > noOpControl.slope2` check + * passes on any measurable difference, including one within GC-timing and JIT-warmup noise, which + * both controls are equally subject to. The no-op baseline is floored at MINIMUM_NOOP_BASELINE_KB + * before multiplying, since a baseline that measures at or below zero (plausible for a control that + * retains nothing) would otherwise make any tiny positive leak slope clear the bar trivially. + */ +const LEAK_CONTROL_MARGIN_FACTOR = 5 +const MINIMUM_NOOP_BASELINE_KB = 0.05 + +interface HeapSlope { + /** KB/iter between the first and second settle points. */ + slope1: number + /** KB/iter between the second and third settle points. */ + slope2: number +} + +const DEFAULT_HEAP_SLOPE_BATCH_SIZES: readonly [number, number, number] = [100, 200, 400] + +/** + * Bytes-per-iteration slope, not a single before/after `gc()` snapshot: a lone snapshot around one + * synchronous run conflates real retention with V8 flushing its own one-time load bytecode after + * warmup, which reads as a spurious multi-megabyte negative delta. Three settle points after + * growing batches of iterations give two slope samples immune to that one-time cost, since it only + * shows up once, before the first settle point. + */ +async function measureHeapSlopeKb( + gc: () => void, + iteration: (index: number) => void, + batchSizes: readonly [number, number, number] = DEFAULT_HEAP_SLOPE_BATCH_SIZES, +): Promise { + const [firstBatch, secondBatch, thirdBatch] = batchSizes + let index = 0 + for (let i = 0; i < firstBatch; i++) iteration(index++) + const h0 = await settleHeap(gc) + for (let i = 0; i < secondBatch; i++) iteration(index++) + const h1 = await settleHeap(gc) + for (let i = 0; i < thirdBatch; i++) iteration(index++) + const h2 = await settleHeap(gc) + return { slope1: (h1 - h0) / secondBatch / 1024, slope2: (h2 - h1) / thirdBatch / 1024 } +} + +function formatKb(value: number): string { + return value.toFixed(2) +} + +function formatSlope(slope: HeapSlope): string { + return `slope1=${formatKb(slope.slope1)} slope2=${formatKb(slope.slope2)}` +} + +/** + * A no-op control (slope must land near zero) and a deliberate-leak control (slope must be clearly + * positive) prove the instrument can tell a leak from noise, rather than trusting an unvalidated + * number. + */ +async function measureHeapSlopes( + gc: () => void, + reportProgress: (stage: string) => void, +): Promise { + reportProgress('retained heap slope (production shape)') + const productionShape = await measureHeapSlopeKb( + gc, + createProductionShapeIteration(), + PRODUCTION_SHAPE_BATCH_SIZES, + ) + + reportProgress('retained heap slope (no-op control)') + const noOpControl = await measureHeapSlopeKb(gc, (index) => { + void (index + 1) + }) + + reportProgress('retained heap slope (leak control)') + const leaked: unknown[] = [] + const leakControl = await measureHeapSlopeKb(gc, (index) => { + leaked.push(createProductionShapeTemplate(index)) + }) + + const report = + `Retained heap slope (production shape): ${formatSlope(productionShape)} KB/iter\n` + + `Retained heap slope (no-op control): ${formatSlope(noOpControl)} KB/iter\n` + + `Retained heap slope (leak control): ${formatSlope(leakControl)} KB/iter` + + /** + * Compares the later, more-settled sample (slope2) rather than the average: a transient + * per-closure JIT cost inflates slope1 for every shape, including the no-op control, and would + * otherwise make the leak control look less clearly separated from noise than it is. Requires a + * margin over a floored baseline, not just "greater than", so noise alone cannot pass this check. + */ + const requiredLeakSlope = + Math.max(noOpControl.slope2, MINIMUM_NOOP_BASELINE_KB) * LEAK_CONTROL_MARGIN_FACTOR + if (leakControl.slope2 <= requiredLeakSlope) { + throw new Error( + `The heap-slope instrument itself looks broken: the leak control (${formatKb(leakControl.slope2)} KB/iter) ` + + `did not measure clearly above the no-op control (${formatKb(noOpControl.slope2)} KB/iter): expected at ` + + `least ${LEAK_CONTROL_MARGIN_FACTOR}x it (floored at ${formatKb(MINIMUM_NOOP_BASELINE_KB)} KB/iter), ` + + `${formatKb(requiredLeakSlope)} KB/iter. Fix the probe before trusting its numbers.\n` + + report, + ) + } + return report +} + +interface BenchmarkReport { + readonly heapSlopes: string + readonly table: ReturnType +} + +/** + * The whole benchmark runs in one worker thread (scripts/bounded-worker.ts), so every task shares + * the warm JIT state a single process gives it, stopped at BENCHMARK_DEADLINE_MS of wall time or + * BENCHMARK_HEAP_CAP_MB of heap. A healthy run, even on a loaded machine, takes a small fraction of + * the deadline and needs a small fraction of the cap, so either stop means a hot operation or a + * cache regressed badly enough that a before-and-after comparison would be meaningless. + */ +const BENCHMARK_DEADLINE_MS = 5 * 60_000 +const BENCHMARK_HEAP_CAP_MB = 1024 + +async function runBenchmark(reportProgress: (stage: string) => void): Promise { + const gc = requireGc('benchmark') + const benchmark = createBenchmark(reportProgress) + await benchmark.run() + const table = benchmark.table() + const heapSlopes = await measureHeapSlopes(gc, reportProgress) + return { heapSlopes, table } +} + +async function main() { + requireGc('benchmark') + const entry = process.argv[1] + if (entry === undefined) { + throw new Error( + 'The benchmark runs in a worker loaded from its own file, found through process.argv[1], ' + + 'which is empty: run it as `corepack yarn benchmark`.', + ) + } + const outcome = await runBounded({ + entry, + input: undefined, + limits: { deadlineMs: BENCHMARK_DEADLINE_MS, heapMb: BENCHMARK_HEAP_CAP_MB }, + }) + const where = + outcome.kind === 'done' || outcome.lastProgress === undefined + ? 'while loading' + : `during ${outcome.lastProgress}` + + switch (outcome.kind) { + case 'done': + console.table(outcome.result.table) + console.log(outcome.result.heapSlopes) + return + case 'deadline': + console.error( + `Benchmark stopped at its ${formatSeconds(BENCHMARK_DEADLINE_MS)} wall-clock deadline ` + + `${where}. A healthy run takes a small fraction of that, so this most likely means a ` + + 'superlinear regression (a quadratic or worse cost, or a loop that never ends) in that ' + + 'operation; `corepack yarn test:scaling` names the hot operation. See ' + + 'docs/architecture.md (substitution and caching invariants).', + ) + break + case 'heap': + console.error( + `Benchmark reached its ${BENCHMARK_HEAP_CAP_MB}MB heap cap ${where}, after ` + + `${formatSeconds(outcome.elapsedMs)}. A healthy run needs a small fraction of that, so ` + + 'this most likely means unbounded retention (a cache that never evicts) or an ' + + 'allocation that grows superlinearly with the input. See docs/architecture.md (caching ' + + 'invariants).', + ) + break + case 'crashed': + console.error(`Benchmark failed ${where}:`, outcome.error) + break + } + process.exitCode = 1 +} + +if (isMainThread) { + void main() +} else { + serveBounded((_input, reportProgress) => + runBenchmark(reportProgress), + ) +} diff --git a/test/performance/validation-cache-key-flattening-probe.mjs b/test/performance/validation-cache-key-flattening-probe.mjs new file mode 100644 index 0000000..de985a3 --- /dev/null +++ b/test/performance/validation-cache-key-flattening-probe.mjs @@ -0,0 +1,130 @@ +/** + * Measures whether RawValidationCache's key (src/features/diagnostics.ts, buildValidationCacheKey + * plus flattenCacheKey) needs its explicit flatten step, or whether inserting and looking up a + * plain concatenated string as an actual Map key already flattens it on this Node version. Plain + * JavaScript, no build step, so it runs unmodified on the package's Node floor as well as the + * development Node version. Run: node --expose-gc test/performance/validation-cache-key-flattening-probe.mjs + * + * Mirrors RawValidationCache's real shape for the "map key" and "flattened map key" cases: each + * iteration builds a fresh key the way buildValidationCacheKey does (the reading key's length, + * ":", the reading key, then rawText, a slice of a large parent string), `Map.set`s it, then + * `Map.get`s a second, separately built but content-equal key (a real cache hit looks up a + * freshly concatenated key, never the same string instance that was inserted). The "value, never + * hashed" case stores the same key as an array element instead, the shape that does retain the + * parent, included so a probe that reports near-zero for every case is caught as broken rather + * than trusted. + */ +const gc = globalThis.gc +if (!gc) { + throw new Error('This probe needs --expose-gc.') +} + +const ITERATIONS = 300 +const PARENT_LENGTH = 500_000 +const SLICE_START = 100_000 +const SLICE_LENGTH = 400 + +/** + * Embeds `seed` at SLICE_START itself, not merely appended at the end: the sliced window below is + * [SLICE_START, SLICE_START + SLICE_LENGTH), and a seed appended after the whole repeated pattern + * never reaches that window, so every iteration's rawText read back as the identical string and the + * Map below collapsed 300 "distinct" keys into one entry, measuring nothing. + */ +function buildFlatParent(seed) { + const marker = String(seed) + const body = 'abcdefghij'.repeat(PARENT_LENGTH / 10) + const withMarker = body.slice(0, SLICE_START) + marker + body.slice(SLICE_START + marker.length) + /** split/join forces a genuinely flat backing buffer, never a ConsString left over from string ops. */ + return withMarker.split('').join('') +} + +/** A realistic reading key: TagKind values (src/virtual-document/styled-virtual-document-provider.ts) are short tag-kind strings. */ +const READING_KEY = 'global-style' + +/** Same shape as buildValidationCacheKey in src/features/diagnostics.ts. */ +function buildValidationCacheKey(readingKey, rawText) { + return `${readingKey.length}:${readingKey}${rawText}` +} + +function flattenCacheKey(key) { + return (' ' + key).slice(1) +} + +/** + * Yields one macrotask before calling `gc()`, mirroring settleHeap + * (test/performance/template-language-service-fixture.ts): a synchronous `gc()` call right after + * the allocations it is meant to collect measured every case here, including a deliberately-held + * whole-parent control, as retaining near zero on this Node version, so it under-collects rather + * than failing loudly. Yielding first is what this probe validated against its self-check below. + */ +async function collectGarbage() { + await new Promise((resolve) => setTimeout(resolve, 0)) + gc() +} + +/** Runs `store` once per iteration with a fresh rawText slice of a fresh parent string. */ +async function measureRetainedMb(store) { + await collectGarbage() + const before = process.memoryUsage().heapUsed + for (let iteration = 0; iteration < ITERATIONS; iteration++) { + const parent = buildFlatParent(iteration) + store(parent.slice(SLICE_START, SLICE_START + SLICE_LENGTH)) + } + await collectGarbage() + const after = process.memoryUsage().heapUsed + return (after - before) / (1024 * 1024) +} + +const mapForKeyCases = new Map() +const mapKeyPlain = await measureRetainedMb((rawText) => { + mapForKeyCases.set(buildValidationCacheKey(READING_KEY, rawText), 1) + /** A fresh, content-equal lookup key, not the same instance. */ + mapForKeyCases.get(buildValidationCacheKey(READING_KEY, rawText)) +}) + +const mapForFlattenedCases = new Map() +const mapKeyFlattened = await measureRetainedMb((rawText) => { + mapForFlattenedCases.set(flattenCacheKey(buildValidationCacheKey(READING_KEY, rawText)), 1) + mapForFlattenedCases.get(buildValidationCacheKey(READING_KEY, rawText)) +}) + +const heldValues = [] +const valueNeverHashed = await measureRetainedMb((rawText) => { + heldValues.push(buildValidationCacheKey(READING_KEY, rawText)) +}) + +console.log(`map key, plain concatenation: ${mapKeyPlain.toFixed(2)} MB retained`) +console.log(`map key, explicitly flattened: ${mapKeyFlattened.toFixed(2)} MB retained`) +console.log(`array value, never hashed: ${valueNeverHashed.toFixed(2)} MB retained`) + +/** + * A key that keeps its parent alive retains a whole parent per iteration, the never-hashed + * control's reading; a flat key retains only its own few hundred bytes. Both the self-check and + * the verdict compare against this share of the control, a margin orders of magnitude past the + * sub-megabyte noise the key readings show, so noise cannot flip either. + */ +const PARENT_RETENTION_SHARE = 0.5 +/** The never-hashed control keeps ITERATIONS parents of PARENT_LENGTH one-byte characters alive, well over this. */ +const MINIMUM_CONTROL_MB = 10 + +/** + * Self-check of the instrument, independent of the plain key's reading (the outcome under test): + * the control must retain its parents and the explicitly flattened key, a copy, must not. A probe + * reading near zero for everything, or high for everything, is broken rather than trusted. + */ +if ( + !(valueNeverHashed > MINIMUM_CONTROL_MB) || + !(mapKeyFlattened < valueNeverHashed * PARENT_RETENTION_SHARE) +) { + throw new Error( + `Expected the never-hashed control to retain over ${MINIMUM_CONTROL_MB} MB and the explicitly ` + + `flattened key under ${PARENT_RETENTION_SHARE} of that (control=${valueNeverHashed.toFixed(2)} MB, ` + + `flattened=${mapKeyFlattened.toFixed(2)} MB). The probe is broken.`, + ) +} + +console.log( + mapKeyPlain - mapKeyFlattened > valueNeverHashed * PARENT_RETENTION_SHARE + ? 'RESULT: a plain concatenated Map key keeps its parent string alive on this Node version (the explicit flatten is load-bearing here).' + : 'RESULT: a plain concatenated Map key retains no parent string on this Node version, the same as the explicitly flattened key (the explicit flatten is redundant here, kept defensively).', +) diff --git a/test/unit/scaling-check-cli.test.ts b/test/unit/scaling-check-cli.test.ts new file mode 100644 index 0000000..f93782c --- /dev/null +++ b/test/unit/scaling-check-cli.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from 'vitest' + +import { + createProgressLocationTracker, + parseFilterArgument, + selectChecks, +} from '../../test/performance/scaling-check-cli.ts' + +describe('parseFilterArgument', () => { + it('returns undefined when --filter is absent', () => { + assert.isUndefined(parseFilterArgument([])) + assert.isUndefined(parseFilterArgument(['--something-else'])) + }) + + it('returns the value following --filter', () => { + assert.strictEqual(parseFilterArgument(['--filter', 'url']), 'url') + assert.strictEqual(parseFilterArgument(['before', '--filter', 'url', 'after']), 'url') + }) + + it('throws when --filter has no value after it', () => { + assert.throws(() => parseFilterArgument(['--filter']), /needs a value/) + }) +}) + +describe('selectChecks', () => { + const checks = [ + { name: 'substitution (single-line)' }, + { name: 'hex escapes in one url() argument' }, + { name: 'folding (many spans)' }, + ] + + it('returns every check, in order, with its original index, when filter is undefined', () => { + const selected = selectChecks(checks, undefined) + assert.deepEqual( + selected.map((entry) => entry.index), + [0, 1, 2], + ) + assert.deepEqual( + selected.map((entry) => entry.check.name), + checks.map((check) => check.name), + ) + }) + + it('returns only checks whose name includes the filter substring, keeping original indices', () => { + const selected = selectChecks(checks, 'url') + assert.deepEqual( + selected.map((entry) => ({ index: entry.index, name: entry.check.name })), + [{ index: 1, name: 'hex escapes in one url() argument' }], + ) + }) + + it('throws loudly, listing every check name, when the filter matches nothing', () => { + let thrown: unknown + try { + selectChecks(checks, 'nonexistent-check-name') + } catch (error) { + thrown = error + } + assert.instanceOf(thrown, Error) + const message = (thrown as Error).message + assert.match(message, /nonexistent-check-name/) + for (const check of checks) { + assert.include(message, check.name) + } + }) +}) + +describe('createProgressLocationTracker', () => { + it('reports "before its first sample" before any progress arrives', () => { + const tracker = createProgressLocationTracker() + assert.strictEqual(tracker.location(), 'before its first sample') + }) + + it('reports the most recent stage', () => { + const tracker = createProgressLocationTracker() + tracker.onProgress({ stage: 'size 6000, run 1 of 5, attempt 1 of 3' }) + assert.strictEqual(tracker.location(), 'at size 6000, run 1 of 5, attempt 1 of 3') + tracker.onProgress({ stage: 'size 24000, run 2 of 5, attempt 1 of 3' }) + assert.strictEqual(tracker.location(), 'at size 24000, run 2 of 5, attempt 1 of 3') + }) + + /** + * The regression this guards: scaling-check.ts's retry line is a free-form "line" progress + * report, sent only after real sampling already happened for that attempt. Before this tracker, + * the "where" computed from only the single most recent progress report read a retry line as + * "before its first sample" whenever it happened to be the last report received before a + * deadline or heap-cap stop, even though sampling had clearly already started. + */ + it('keeps the last stage after a later "line" progress report, never reverting to "before its first sample"', () => { + const tracker = createProgressLocationTracker() + tracker.onProgress({ stage: 'size 6000, run 5 of 5, attempt 1 of 3' }) + tracker.onProgress({ line: 'retry 1/2 some-check: N=6000 ..., measuring again' }) + assert.strictEqual(tracker.location(), 'at size 6000, run 5 of 5, attempt 1 of 3') + }) +}) diff --git a/test/unit/tsserver-message-reader.test.ts b/test/unit/tsserver-message-reader.test.ts new file mode 100644 index 0000000..cb85dfb --- /dev/null +++ b/test/unit/tsserver-message-reader.test.ts @@ -0,0 +1,57 @@ +import { assert, describe, it } from 'vitest' + +import { TSServerMessageReader } from '../e2e/tsserver-fixture/message-reader' + +function frame(message: string) { + const body = Buffer.from(message) + return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) +} + +describe('TSServerMessageReader', () => { + it('keeps a partial header buffered until the separator arrives', () => { + const reader = new TSServerMessageReader() + const message = JSON.stringify({ type: 'response', command: 'quickinfo' }) + const framedMessage = frame(message) + const splitAt = framedMessage.indexOf('\r\n\r\n') - 2 + + assert.deepEqual(reader.push(framedMessage.subarray(0, splitAt)), []) + assert.deepEqual(reader.push(framedMessage.subarray(splitAt)), [message]) + }) + + it('uses UTF-8 byte lengths for non-ASCII messages', () => { + const reader = new TSServerMessageReader() + const firstMessage = JSON.stringify({ message: '颜色' }) + const secondMessage = JSON.stringify({ message: 'next' }) + const firstFrame = frame(firstMessage) + const multibyteCharacter = '颜' + const multibyteCharacterByteLength = Buffer.byteLength(multibyteCharacter, 'utf8') + const multibyteCharacterStart = firstFrame.indexOf(Buffer.from(multibyteCharacter)) + const splitAt = multibyteCharacterStart + 1 + + /** + * Pins the precondition the test's name claims rather than only ruling out "not found" (-1): + * the split point must fall strictly inside the multi-byte character's own bytes, never on or + * before its first byte or on/past its last, so a change that stopped splitting mid-character + * (for example by counting string length instead of UTF-8 byte length) fails this test instead + * of passing it by accident. + */ + assert.isAbove(multibyteCharacterStart, 0) + assert.isAbove(splitAt, multibyteCharacterStart) + assert.isBelow(splitAt, multibyteCharacterStart + multibyteCharacterByteLength) + assert.deepEqual(reader.push(firstFrame.subarray(0, splitAt)), []) + assert.deepEqual( + reader.push(Buffer.concat([firstFrame.subarray(splitAt), frame(secondMessage)])), + [firstMessage, secondMessage], + ) + }) + + it('fails loudly on a header block with no Content-Length, naming the header text', () => { + const reader = new TSServerMessageReader() + const header = 'X-Not-A-Real-Header: 1' + + assert.throws( + () => reader.push(Buffer.from(`${header}\r\n\r\n`)), + `tsserver wrote a header with no "Content-Length": ${header}`, + ) + }) +}) From d8fc730e8e5c5f569dffffbf7eff42b257a18109 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sun, 27 Sep 2026 14:55:10 -0400 Subject: [PATCH 4/5] docs: split usage, maintenance, architecture, and host guidance, and add AGENTS.md Co-authored-by: usercao --- AGENTS.md | 107 +++++ README.md | 502 +++++---------------- docs/architecture.md | 161 +++++++ {documentation => docs/images}/preview.gif | Bin docs/maintenance.md | 467 +++++++++++++++++++ docs/tsserver-host.md | 54 +++ docs/usage.md | 304 +++++++++++++ 7 files changed, 1213 insertions(+), 382 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/architecture.md rename {documentation => docs/images}/preview.gif (100%) create mode 100644 docs/maintenance.md create mode 100644 docs/tsserver-host.md create mode 100644 docs/usage.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ab9b5a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +AGENTS.md: instructions for any agent or contributor working in this repository. + +Project: `@styled/typescript-styled-plugin`, a TypeScript language service (tsserver) plugin that adds CSS completions, diagnostics, quick fixes, hover, folding, and Emmet inside styled-components tagged templates. Published to npm; bundled by the vscode-styled-components extension. Public OSS repository. + +Map + +- docs/architecture.md: spec for request flow, substitution and virtual-document invariants, position mapping, completion merging, caching, configuration, public surface. Read before touching src/. +- docs/tsserver-host.md: verified facts about tsserver plugin loading, supported TypeScript and Node versions, host runtimes, the decorator dependency, tag matching, and tsserver test-harness pitfalls. +- docs/usage.md: user-facing configuration (tags, validation, lint, Emmet) and host requirements. +- docs/maintenance.md: commands, project layout, focused tests, tsserver debugging, packaging, releases. +- CHANGELOG.md: released, user-visible changes, assembled by the release workflow from `.changeset/`; never edited by hand. +- .changeset/: one file per pending noteworthy change or fix, consumed by the next release (Git and releases, below). +- src/api.ts: the public API (`./api` export). src/index.ts: the tsserver entry. src/features/: one file per language feature. +- scripts/: build, packaging, and diagnostic tooling behind the commands below. +- test/unit (vitest, imports src and scripts/), test/e2e (a shared tsserver per suite file, runs the built lib/), test/package-api (packed tarball consumed from ESM and CommonJS), test/performance (benchmark, scaling check, and a cache-key retention probe). + +Commands + +- Package manager: Yarn 4 through corepack (`corepack yarn ...`); setup and install in docs/maintenance.md, "Requirements". +- `corepack yarn verify` runs format check, lint (warnings fail), typecheck, unit, e2e (default TypeScript version), scaling, package API, pack contents (`yarn test:pack`). CI additionally gates on the TypeScript e2e matrix (`corepack yarn test:e2e:minimum`, `:5.9`, `:current`) and a Node-floor tsserver smoke job (docs/maintenance.md, "Testing changes"); run both locally before relying on a passing `verify` alone for a change near that boundary. +- Only composite commands build (`verify`, `test:e2e*`, `pack:artifact`); every other check reads the existing lib/ or packed tarball (docs/maintenance.md, "Running tests"). +- e2e against another TypeScript: set `TSSERVER_TYPESCRIPT_PACKAGE` to an alias declared in test/e2e/package.json. +- `corepack yarn benchmark` prints completion/diagnostics/hover/folding latency and retained-heap slopes (KB per iteration between settle points, with a no-op and a deliberate-leak control). +- `corepack yarn compare-release [cases...]` prints the diagnostics, completions, hovers, folds, and code fixes that differ between `lib/` and a published release (docs/maintenance.md, "Comparing with a release"). + +Rules + +Prose + +- American English, plain words, jargon defined on first use. No em-dashes in written files. +- Docs state current behavior; no narration of history. One fact, one home: link to the home instead of restating. +- Error and log messages name the problem, where it occurred, and the fix (for example the required TypeScript version and the detected one). +- No attribution footers or trailers in commits, PRs, or files. + +User experience + +- Least surprise for the plugin's users: code that works at runtime in a supported styled-components version produces no plugin diagnostic, and when unsure, stay silent rather than report a false error. Completions offer what a user would plausibly type in that position. +- Contributor tooling (`verify`, the scaling check, the pack checks) fails only for real defects, never for machine load or environment noise. +- Legacy CSS syntax that nearly no current user writes (vendor-prefixed at-rules and similar) is out of scope and not worth adding support for, unless 1.0.1 already supported it. + +Correctness and types + +- TypeScript strict; no `as any`, non-null assertions, or `@ts-expect-error` outside a marked negative type test. +- The tsserver boundary is untrusted input: plugin configuration arrives as `unknown` and is normalized, never assumed. +- Never let an exception escape into tsserver: a throw fails the whole response for the file, including TypeScript's own diagnostics. +- Never call `TemplateContext.toOffset` or `toPosition` with a value outside the template (tsserver asserts on closed files). +- Substitution output keeps the input length exactly; mapping never relies on the substituted text's line breaks (docs/architecture.md). +- Public surface (package root factory, `./api` exports, emitted `.d.ts`) is backward compatible or ships in a `major` changeset that states the break. Internal code may be rewritten freely. +- Emitted declarations must compile under `skipLibCheck: false` on the minimum and maximum supported TypeScript, plus two versions in between, and under `moduleResolution: node10` and `bundler`; `corepack yarn test:package-api:consumers` runs every gate, one `test/package-api/tsconfig.consumers..json` each, so adding a TypeScript version means adding one tsconfig (docs/architecture.md, "Public surface"). +- Version support covers what the last release demonstrably ran on, plus newer hosts; never widen support backward to a host the last release did not actually work on, even when its code gate admitted it (docs/tsserver-host.md records the measured floors). A host below the floor gets a logged message and an untouched language service. +- 1.0.1's deep-import surface (no `exports` map at all) is a compatibility floor, not a spec: a new subpath keeps resolving the way it did in 1.0.1 (`./lib/index`, `./lib/index.js`, `./lib/api`, `./lib/api.js`), or the removal ships in a `major` changeset. + +Testing + +- Every behavior change ships with tests; prove each new test fails before the fix and passes after. +- Test real behavior headlessly: unit tests for mapping and translation logic, e2e for anything that depends on real tsserver behavior (closed files, protocol, plugin loading). +- Assert exact values derived from the input (offsets, spans, messages), never values copied from a run. Prefer full-output assertions over `length > 0`. +- Every "returns nothing" assertion needs a positive control in the same setup, since a plugin that failed to load also returns nothing. +- Cover edge shapes: multi-line interpolations, CRLF, U+2028/U+2029, empty and whitespace-only templates, unclosed blocks, `keyframes`, nested `css`, `.attrs()`, generics, files not open in the editor. +- Supported host matrix (TypeScript 5.0 floor through current 6.x; Node 14.21.3 floor) runs in CI via the e2e matrix plus the `tsserver-smoke-floor` job (the vitest-based e2e suite cannot run on the package's own Node floor, since vitest declares a newer `engines.node`). The `tsserver-smoke-legacy-typescript` job proves the plugin instead logs a message and leaves tsserver responsive on TypeScript 4.9.5, below that floor (docs/tsserver-host.md). Add an e2e matrix row when the TypeScript 5.0-or-newer support range changes, and update `tsserver-smoke-floor` when the Node floor changes. +- Local test runs stay fast; investigate any slow test instead of raising timeouts. + +Organization and naming + +- One language feature per file in src/features/; shared mapping lives in src/virtual-document/. +- Alphabetize fields in type declarations and object literals unless order is load-bearing (then comment why). +- Prefer exported constants over repeated string literals (tag names, diagnostic codes, plugin identity). +- A source file past about 1,000 lines prompts a split. + +Comments + +- Block comments (`/** */`) only, for design decisions and non-obvious behavior; never restate types or narrate a change. +- Code comments are not authoritative; the spec in docs/architecture.md is. Update the spec first for invariant changes. + +Security and supply chain + +- Package lifecycle scripts stay disabled; enable one only for demonstrated breakage with a one-line justification. +- Yarn's `npmMinimalAgeGate` holds back freshly published versions; do not bypass it. +- Lockfile changes accompany every dependency change and are verified by a full test run. +- No new runtime dependency without need; runtime dependencies must be CommonJS-loadable or bundled, with no top-level await (docs/tsserver-host.md). + +Performance + +- Completion and hover run per keystroke. Validate hot-path changes with `corepack yarn benchmark` before and after, with a realistic `TemplateContext` fixture; a slowdown is a regression. +- The happy path does not pay for edge cases: handling for a rare input shape runs only after a cheap check proves that shape is present, so a template without it costs the same as before the handling existed. Show it with a before-and-after benchmark on templates without the shape. +- Do not record measured numbers in docs; state the command that prints them. +- No per-result or per-placeholder work proportional to template length (a per-placeholder slice from the line start, a per-diagnostic line-map rebuild). A multi-entry cache keyed by template text copies the key (docs/architecture.md, Caching), never stores a slice of the source string, and is bounded by total size, not only entry count (every keystroke adds a distinct key). `corepack yarn test:scaling` (`test/performance/scaling-check.ts`) gates superlinear time, wrong results, implausibly flat timing, and retained memory in CI (docs/maintenance.md, "Scaling guardrail"); add a check there for a new hot operation or cache. + +Git and releases + +- Noteworthy changes and bugfixes get a changeset (`corepack yarn changeset`). Changeset bodies use declarative release-note voice (what changed, for whom), never first-person "I", and no conventional-commit prefixes. Match the tone of existing `.changeset/*.md` entries. +- Before crediting anyone in a changeset, derive the list rather than recalling it: `corepack yarn changeset-credits [name]` reports every commit author and co-author who introduced or edited that changeset file, pending or already released. +- Version bumps and npm publishing happen only through the release workflow (`.github/workflows/release.yml`), from accumulated changesets; contributions leave `version` alone and never publish by hand. +- `scripts/changelog.cjs` is the changelog generator, wired in `.changeset/config.json` along with the `maintainers` list it reads. Attribution comes from git history, so the release job checks out full history rather than a shallow clone. +- Conventional commit subjects (`fix:`, `feat:`, `test:`, `docs:`, `chore:`) on commits and PR titles. Never `git stash`; use a temporary commit. +- Keep PRs to one kind of change (tooling, behavior, packaging) so each can be reviewed and reverted alone. + +Method + +- A reported defect names a class: search for every instance (code, tests, docs, CI) and fix all of them in the same change, or list the rest. +- Verify ecosystem claims (TypeScript, Node, editor hosts, dependencies) against primary sources or experiment before relying on them; record durable ones in docs/tsserver-host.md with a date. +- Fix causes, not symptoms: no swallowing catches around logic errors, no raised timeouts over races, no snapshots updated to green. +- Before reporting work complete or opening a PR, run an independent adversarial review with a fresh context. + +Not applicable: UI and visual design (headless library), database migrations. + +Tracker: GitHub Issues on styled-components/typescript-styled-plugin. AGENTS.md is not a changelog or backlog. diff --git a/README.md b/README.md index ccfa588..a9abf3b 100644 --- a/README.md +++ b/README.md @@ -1,427 +1,165 @@ # TypeScript Styled Plugin -TypeScript server plugin that adds intellisense to [styled component](https://styled-components.com) css strings +Cross-editor TypeScript Server plugin for CSS IntelliSense in [styled-components](https://styled-components.com) template literals. -![](documentation/preview.gif) +![](docs/images/preview.gif) ![Build Status](https://github.com/styled-components/typescript-styled-plugin/actions/workflows/ci.yml/badge.svg) -**Features** +## Features -- IntelliSense for CSS property names and values. -- Syntax error reporting. -- Quick fixes for misspelled property names. +- IntelliSense for CSS property names and values. +- Syntax error reporting. +- Quick fixes for misspelled property names. +- Hover information. +- Folding. +- Emmet completions. -## Usage +## Requirements -This plugin requires TypeScript 2.4 or later. It can provide intellisense in both JavaScript and TypeScript files within any editor that uses TypeScript to power their language features. This includes [VS Code](https://code.visualstudio.com), [Sublime with the TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin), [Atom with the TypeScript plugin](https://atom.io/packages/atom-typescript), [Visual Studio](https://www.visualstudio.com), and others. +- TypeScript 5.0 or newer (6.x recommended) activates the plugin. Below that + floor the plugin logs a message and leaves the host's `LanguageService` + untouched instead of activating. +- Node.js 14.21.3 or newer in the tsserver host. The plugin's package root is + CommonJS, loaded with tsserver's synchronous `require()`. -### With VS Code - -Just install the [VS Code Styled Components extension](https://github.com/styled-components/vscode-styled-components). This extension adds syntax highlighting and IntelliSense for styled components in JavaScript and TypeScript files. +The automated suite covers the standard Node.js tsserver path. An editor must +use a compatible tsserver host and runtime; successful installation alone does +not prove editor compatibility. See +[docs/tsserver-host.md](docs/tsserver-host.md) for the verified version and +loading details behind these requirements. -If you are using a [workspace version of TypeScript](<(https://code.visualstudio.com/Docs/languages/typescript#_using-newer-typescript-versions)>) however, you must manually install the plugin along side the version of TypeScript in your workspace: +Embedding the language service directly through +`@styled/typescript-styled-plugin/api` instead of the tsserver plugin needs +the same Node floor, for both `import()` and `require()`. -```bash -npm install --save-dev @styled/typescript-styled-plugin typescript -``` +## Quick Start -Then add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html) or [`jsconfig.json`](https://code.visualstudio.com/Docs/languages/javascript#_javascript-project-jsconfigjson) - -```json -{ - "compilerOptions": { - "plugins": [ - { - "name": "@styled/typescript-styled-plugin" - } - ] - } -} -``` - -Finally, run the `Select TypeScript version` command in VS Code to switch to use the workspace version of TypeScript for VS Code's JavaScript and TypeScript language support. You can find more information about managing typescript versions [in the VS Code documentation](https://code.visualstudio.com/Docs/languages/typescript#_using-newer-typescript-versions). - -### With Sublime - -This plugin works with the [Sublime TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin). - -First install the plugin and a copy of TypeScript in your workspace: +Install the plugin alongside the workspace TypeScript SDK: ```bash -npm install --save-dev @styled/typescript-styled-plugin typescript -``` - -And configure Sublime to use the workspace version of TypeScript by [setting the `typescript_tsdk`](https://github.com/Microsoft/TypeScript-Sublime-Plugin#note-using-different-versions-of-typescript) setting in Sublime: - -```json -{ - "typescript_tsdk": "/Users/matb/my-amazing-project/node_modules/typescript/lib" -} +npm install --save-dev @styled/typescript-styled-plugin typescript@^6.0.3 ``` -Finally add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html) or [`jsconfig.json`](https://code.visualstudio.com/Docs/languages/javascript#_javascript-project-jsconfigjson) and restart Sublime. - ```json { - "compilerOptions": { - "plugins": [ - { - "name": "typescript-styled-plugin" - } - ] - } + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin" + } + ] + } } ``` -### With Visual Studio - -This plugin works [Visual Studio 2022](https://www.visualstudio.com) using the TypeScript 4.9+ SDK. - -First install the plugin in your project: +Configure the editor to use this workspace TypeScript SDK. See the +[usage guide](docs/usage.md) for tag configuration, validation and lint +properties, and Emmet completions. -```bash -npm install --save-dev @styled/typescript-styled-plugin -``` - -Then add a `plugins` section to your [`tsconfig.json`](http://www.typescriptlang.org/docs/handbook/tsconfig-json.html). - -```json -{ - "compilerOptions": { - "plugins": [ - { - "name": "@styled/typescript-styled-plugin" - } - ] - } -} -``` - -Then reload your project to make sure the plugin has been loaded properly. Note that `jsconfig.json` projects are currently not supported in VS. - -## Configuration - -### Tags - -This plugin adds styled component IntelliSense to any template literal [tagged](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) with `styled`, `css`, `injectGlobal`, `keyframes` or `createGlobalStyle`: - -```js -import styled from 'styled-components'; - -styled.button` - color: blue; -`; -``` - -You can enable IntelliSense for other tag names by configuring `"tags"`: - -```json -{ - "compilerOptions": { - "plugins": [ - { - "name": "@styled/typescript-styled-plugin", - "tags": ["styled", "css", "sty"] - } - ] - } -} -``` - -Now strings tagged with either `styled`, `css`, or `sty` will have styled component IntelliSense: - -```js -import sty from 'styled-components'; - -sty.button` - color: blue; -`; -``` +## Editor Integration -Tags also apply to methods on styled components. This is enabled for `extend` by default: - -```js -import sty from 'styled-components'; - -const BlueButton = sty.button` - color: blue; -`; - -const MyFancyBlueButton = BlueButton.extend` - border: 10px solid hotpink; -`; -``` +### With VS Code -### Linting +Install the [VS Code Styled Components extension](https://github.com/styled-components/vscode-styled-components). +It bundles this plugin and works with VS Code's bundled TypeScript version +without installing anything else. The extension depends on `^1.0.0`, so a new +1.x release of this plugin reaches extension users the next time the +extension rebuilds its dependencies; a new major version needs the extension +to widen that range first (docs/tsserver-host.md, "Downstream"). -To disable error reporting, set `"validate": false` in the plugin configuration: +To use a specific plugin version, or a workspace TypeScript version instead of +the one VS Code bundles, complete the [Quick Start](#quick-start), then run +`Select TypeScript Version` in VS Code and choose the workspace version. See +the [VS Code TypeScript documentation](https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-newer-typescript-versions) +for details on managing TypeScript versions. This setup path requires +validation against the specific VS Code and workspace TypeScript versions in +use. -```json -{ - "compilerOptions": { - "plugins": [ - { - "name": "typescript-styled-plugin", - "validate": false - } - ] - } -} -``` +### With Sublime Text -You can also configure how errors are reported using linter settings. +This plugin works with the [Sublime TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin). +Complete the [Quick Start](#quick-start), then point Sublime at the workspace +TypeScript version by setting +[`typescript_tsdk`](https://github.com/Microsoft/TypeScript-Sublime-Plugin#note-using-different-versions-of-typescript): ```json { - "compilerOptions": { - "plugins": [ - { - "name": "typescript-styled-plugin", - "lint": { - "vendorPrefix": "error", - "zeroUnits": "ignore" - } - } - ] - } + "typescript_tsdk": "/node_modules/typescript/lib" } ``` -The following lint options are supported: - -#### validProperties - -``` -["property1", "property2", ....] -``` - -List of properties that are treated as valid. - -#### unknownProperties - -``` -"ignore" | "warning" | "error" -``` - -Should unknown properties show an error or warning? Default is `"warning"`. - -#### compatibleVendorPrefixes - -``` -"ignore" | "warning" | "error" -``` - -When using a vendor-specific prefix make sure to also include all other vendor-specific properties. Default is `"ignore"`. - -#### vendorPrefix - -``` -"ignore" | "warning" | "error" -``` - -When using a vendor-specific prefix also include the standard property. Default is `"warning"`. - -#### duplicateProperties - -``` -"ignore" | "warning" | "error" -``` - -Do not use duplicate style definitions. Default is `"ignore"`. - -#### emptyRules - -``` -"ignore" | "warning" | "error" -``` - -Do not use empty rulesets. Default is `"ignore"`. - -#### importStatement - -``` -"ignore" | "warning" | "error" -``` - -Import statements do not load in parallel. Default is `"ignore"`. - -#### boxModel - -``` -"ignore" | "warning" | "error" -``` - -Do not use width or height when using padding or border. Default is `"ignore"`. - -#### universalSelector - -``` -"ignore" | "warning" | "error" -``` - -The universal selector (\*) is known to be slow. Default is `"ignore"`. - -#### zeroUnits - -``` -"ignore" | "warning" | "error" -``` - -No unit for zero needed. Default is `"ignore"`. - -#### fontFaceProperties +This setup path requires validation against the Sublime TypeScript plugin and +its bundled Node runtime; that runtime must meet the requirements above. -``` -"ignore" | "warning" | "error" -``` - -@font-face rule must define 'src' and 'font-family' properties. Default is `"warning"`. - -#### hexColorLength - -``` -"ignore" | "warning" | "error" -``` - -Hex colors must consist of three or six hex numbers. Default is `"error"`. - -#### argumentsInColorFunction - -``` -"ignore" | "warning" | "error" -``` +### With Neovim -Invalid number of parameters. Default is `"error"`. - -#### ieHack - -``` -"ignore" | "warning" | "error" -``` - -IE hacks are only necessary when supporting IE7 and older. Default is `"ignore"`. - -#### unknownVendorSpecificProperties - -``` -"ignore" | "warning" | "error" -``` - -Unknown vendor specific property. Default is `"ignore"`. - -#### propertyIgnoredDueToDisplay - -``` -"ignore" | "warning" | "error" -``` - -Property is ignored due to the display. E.g. with 'display: inline', the width, height, margin-top, margin-bottom, and float properties have no effect. Default is `"warning"` - -#### important - -``` -"ignore" | "warning" | "error" -``` - -Avoid using !important. It is an indication that the specificity of the entire CSS has gotten out of control and needs to be refactored. Default is `"ignore"`. - -#### float - -``` -"ignore" | "warning" | "error" -``` - -Avoid using 'float'. Floats lead to fragile CSS that is easy to break if one aspect of the layout changes. Default is `"ignore"`. - -#### idSelector - -``` -"ignore" | "warning" | "error" -``` - -Selectors should not contain IDs because these rules are too tightly coupled with the HTML. Default is `"ignore"`. - -### Emmet in completion list - -You can now see your Emmet abbreviations expanded and included in the completion list. -An [upstream issue](https://github.com/Microsoft/TypeScript/issues/21999) with typescript blocks the Emmet entry in the completion list to get updated as you type. -So for now you will have to press `Ctrl+Space` after typing out the abbreviation. - -The below settings which are in sync with general Emmet settings in VS Code control the expanded Emmet abbreviations in the auto-completion list. - -#### showExpandedAbbreviation - -``` -"always" | "never" -``` - -Controls whether or not expanded Emmet abbreviations should show up in the completion list - -#### showSuggestionsAsSnippets - -``` -`true` | `false` -``` - -If true, then Emmet suggestions will show up as snippets allowing you to order them as per editor.snippetSuggestions setting. - -#### preferences - -Preferences used to modify behavior of some actions and resolvers of Emmet. - -## Contributing - -To build the typescript-styled-plugin, you'll need [Git](https://git-scm.com/downloads) and [Node.js](https://nodejs.org/). - -First, [fork](https://help.github.com/articles/fork-a-repo/) the typescript-styled-plugin repo and clone your fork: +Neovim talks to tsserver through a language server that wraps it. Two of them +load tsserver plugins without a `plugins` entry in `tsconfig.json`: install +the plugin globally, then point the server at npm's global folder, which +`npm root -g` prints. ```bash -git clone https://github.com/YOUR_GITHUB_ACCOUNT_NAME/typescript-styled-plugin.git -cd typescript-styled-plugin -``` - -Then install dev dependencies: - -```bash -npm install -``` - -The plugin is written in [TypeScript](http://www.typescriptlang.org). The source code is in the `src/` directory with the compiled JavaScript output to the `lib/` directory. Kick off a build using the `compile` script: - -```bash -npm run compile -``` - -switch to `e2` to install or update test dependencies: - -```bash -(cd e2e && npm install) -``` - -and then navigate back to the project root and run the end to end tests with the `e2e` script: - -```bash -cd .. -npm run e2e -``` - -You can submit bug fixes and features through [pull requests](https://help.github.com/articles/about-pull-requests/). To get started, first checkout a new feature branch on your local repo: +npm install --global @styled/typescript-styled-plugin +npm root -g +``` + +With [vtsls](https://github.com/yioneko/vtsls) and +[nvim-lspconfig](https://github.com/neovim/nvim-lspconfig), list the plugin in +`vtsls.tsserver.globalPlugins`, with `location` set to that folder: + +```lua +vim.lsp.config('vtsls', { + settings = { + vtsls = { + tsserver = { + globalPlugins = { + { + name = '@styled/typescript-styled-plugin', + location = '/usr/local/lib/node_modules', -- the output of `npm root -g` + enableForWorkspaceTypeScriptVersions = true, + }, + }, + }, + }, + }, +}) +vim.lsp.enable('vtsls') +``` + +With [typescript-tools.nvim](https://github.com/pmizio/typescript-tools.nvim), +list the plugin in `tsserver_plugins`; it finds npm's global folder on its +own: + +```lua +require('typescript-tools').setup({ + settings = { + tsserver_plugins = { '@styled/typescript-styled-plugin' }, + }, +}) +``` + +To change [plugin settings](docs/usage.md), add the plugin's entry to +`compilerOptions.plugins` in `tsconfig.json` as in the +[Quick Start](#quick-start); tsserver still finds the globally installed copy. +To use a copy installed in the project instead, complete the Quick Start and +have the server use the workspace TypeScript version (for vtsls, set +`vtsls.autoUseWorkspaceTsdk` to `true`). The tsserver host must meet the +requirements above. -```bash -git checkout -b my-awesome-new-feature-branch -``` - -Make the desired code changes, commit them, and then push the changes up to your forked repository: +### With Visual Studio -```bash -git push origin my-awesome-new-feature-branch -``` +This setup path requires validation against the installed Visual Studio +TypeScript Server host and runtime. Complete the [Quick Start](#quick-start) in +the project, then confirm Visual Studio loads the workspace TypeScript SDK. Its +tsserver host must meet the requirements above. Visual Studio does not support +`jsconfig.json` projects; use `tsconfig.json`. -Then [submit a pull request](https://help.github.com/articles/creating-a-pull-request/) against the Microsoft typescript-styled-plugin repository. +## Maintainers -Please also see our [Code of Conduct](CODE_OF_CONDUCT.md). +See the [maintenance guide](docs/maintenance.md) for local setup, scripts, +testing, performance baselines, package validation, and pull request guidance. ## Credits -Code originally forked from: https://github.com/Quramy/ts-graphql-plugin +Originally forked from [Quramy/ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c561e5a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,161 @@ +# Architecture + +Spec for how the plugin turns a tsserver request inside a tagged template into a CSS language service call and back. Authoritative for the invariants below; change this file first when changing them. + +## Request flow + +1. tsserver `require()`s the package root (`src/index.ts`), which returns a factory producing `TsServerStyledPlugin` (`src/tsserver/tsserver-plugin.ts`). +2. `create()` checks the host TypeScript version, then wraps the host language service with `decorateWithTemplateLanguageService` from `typescript-template-language-service-decorator`. +3. The decorator finds tagged templates whose tag matches a configured tag name (matching rules: `docs/tsserver-host.md`, "Tag matching"), replaces each `${...}` via `getTemplateSubstitutions` (`src/template/template-substitutions.ts`), and calls `StyledTemplateLanguageService` (`src/template-language-service.ts`) with a `TemplateContext` and template-relative positions. +4. Each feature (`src/features/*`) asks the virtual document provider for an SCSS document, calls `vscode-css-languageservice`, and translates results back to template-relative offsets. The decorator shifts those to file offsets. + +## Substitution invariants + +- Output length equals input length, always. Every template offset is also a virtual-text offset (plus the wrapper length). +- Every fill keeps each line terminator of its placeholder where it stands and writes its characters over the others in order (`$a\n:0` for a mixin broken right after `${`, which the parser reads as whitespace before the `:`), except the hex fill, whose `000` covers the placeholder's first three characters so the digits stay on one line. A placeholder shorter than a branch's fill gets the next fill in that branch's chain (below), ending at the plain fill: the replacement character over every character but line terminators. Position mapping must not depend on the substituted text's line breaks. +- Cost is linear in template length for any number or arrangement of placeholders (many on one line included), and builds no per-character tables over the whole text: the document is rebuilt after every edit, so a whole-text pass is paid per keystroke. Forward scans are lazy and shared across placeholders (each kind of scan remembers the range it last covered, so a later query inside that range reuses the answer; the selector list scan below and the search for a comment's closing `*/` do the same). The scans that must know what is code (the selector-shape search, the selector list scan, and the running statement boundaries that set where each placeholder's statement starts) read the text's characters directly and step over its non-code runs (comments, strings, escapes, and unquoted url() arguments) whole, each scan keeping its own place in one list of those runs, since each placeholder lies past the previous one. One forward walk with the boundary scanner finds the list, stopping only where the scanner can act (a quote, `/`, a backslash, `)`, or a `u` before `r` or a backslash), so a text with few such characters, the common case, costs about one search more than plain code. The selector list scan reads a line's last code character back over that line's trailing whitespace and comments only. The running line facts (whether the line holds non-whitespace before a placeholder, and its latest line break or statement boundary, where a line break or non-whitespace character inside a comment or string still counts) jump from one line break or statement boundary to the next and read each stretch between for non-whitespace with one search. The statement rule's look-back past comments jumps between the characters that can open a comment or string instead of visiting every character; the at-rule condition test looks back over at most one gap and one word, and reads each statement's leading whitespace and comments once. The JavaScript escape replacement below is one pass over the masked text, or one search when the text holds no backslash. A template with no placeholders is returned unchanged. `corepack yarn test:scaling` guards the linear bound; a before-and-after benchmark guards the constant factor. +- Placeholder spans: every scan walks them in ascending order, clamped into the template, with overlapping spans merged into their union (spans that only touch stay separate, as adjacent placeholders do) and empty spans dropped (they substitute nothing). The decorator supplies spans in that shape already; a span list from `./api`'s `getSubstitutions` in any other order, overlapping, empty, or out of range is normalized once, with one copy and one sort. The output depends only on span positions, so the same spans in any order give the same output. +- Each branch of `getSubstitution` targets one placeholder shape (property value, whole declaration, property name, selector, at-rule condition, hex color); comments in `src/template/template-substitutions.ts` show each shape. +- Masked text: the template text with every placeholder as x's (its line terminators kept) and JavaScript escapes replaced as the virtual document replaces them (the "Virtual document" section), so a JavaScript-escaped quote, `;`, or brace reads as the character styled-components receives. Every rule below reads the masked text; the output keeps the raw text between placeholders. +- Name characters, for the rules below: ASCII letters, digits, `-`, `_`, and every UTF-16 code unit from U+0080 up except U+2028 and U+2029, which are line terminators, in the masked text (where adjacent placeholders form one name). This is `vscode-css-languageservice` 6.3.10's `_identChar`, so a non-ASCII space such as U+00A0 is a name character, as the CSS scanner reads it. The same set serves every name reader in the "Virtual document" section: the boundary scanner, the `@layer` name, and the `url` function name in the JavaScript escape replacement. +- Block position (a placeholder standing in for whole declarations, such as a mixin or a `css` fragment) is filled with whitespace, or, when a `;` follows it, with the dummy declaration `$a:0` (then `a:0`, then whitespace, for a shorter placeholder) if the statement rule's look-back below holds and with x's otherwise (nothing before it to anchor a declaration to). A placeholder is in block position when either rule holds: + - Statement rule: the last significant character before it in the masked text, across lines and past comments (a string counts as one significant character at its opening quote, an escape at its backslash, an unquoted `url(...)` argument at its `(`), is `;`, `{`, `}`, or the template start, or is the end of a preceding placeholder that the statement rule itself placed in block position (so `${a} ${b}` on one line is two mixins, and so is `color: red; /* note */ ${a}`); and the next non-whitespace character after it is none of `{`, `:`, `,`, `&`, `.`, `#`, `[`, `>`, `+`, `~`, `*`, `%` (a rule body, a property name, a selector list, a selector continuation, or a percentage such as the keyframe selector `${step}% {`). A placeholder inside a comment or string never passes this rule. + - Matches styled-components v7's slot classification (`isStandaloneSlot` in `packages/styled-components/src/parser/source.ts`, an undocumented internal), except `,` and `%`: v7 excludes neither, and the plugin adds `,` so a placeholder that starts a selector list stays x-filled, the safe direction, and `%` so a placeholder before `%` stays a number. + - Not modeled: v7's missing-semicolon recovery (`shouldRecoverFragmentSlot`), which promotes a slot to block position when its value is a `css` fragment containing `;`, `{`, or `}` (`color: red ${fragment};`). The plugin sees only the `${...}` syntax, never the fragment's value, so such a slot stays a value. + - Line rule: only whitespace precedes it on its own line, and the next non-whitespace character after it is none of `{`, `:`, `,`, `%`. A placeholder placed only by this rule never passes block position on to the next placeholder, so `padding:\n ${a} ${b};` and `border:\n ${a}\n ${b};` keep `${b}` a value. +- Property name: the next non-whitespace character after the placeholder, or after the name characters joined directly to its end, is `:`, without the selector shape below. Such a placeholder is never in block position. Filled with `$a` plus x's (x's for a one-character placeholder), except: + - Inside a custom property name (the statement starts with `--`): x's. + - Joined to name characters directly before or after it (`padding-${side}:`, `${side}-top:`, `border-${side}-color:`): the Sass interpolation `#{x}` plus spaces, which the parser reads as part of one property name and the linter never reports as an unknown property. A placeholder shorter than 4 characters gets x's. +- Selector: that `:` is followed, on its own line, by a `{` or `&` at code with no `;` or `}` at code before it (a `;` or `}` ends the declaration first, so `padding-${s}: 0; &:hover {` and `${p}: 0; &:hover {` keep a property name), or by a line break at code after which the next non-whitespace character is `{` (`${Child}:not(:last-child)` with `{` on the next line). At code means outside comments, strings, escapes, and unquoted `url(...)` arguments, read with the boundary scanner (the "Virtual document" section): a `;`, `{`, `}`, `&`, or line break inside one decides nothing (`${Link}:hover /* a; b */ {` and `${Link}:not([x="a;b"]) {` are selectors, `${p}: "a{b";` a property name), and a `//` comment ends at its line break, which counts. A line whose last code character is `,` (comments skipped) continues a selector list: the search goes on over the next line, and over each further line ending in `,`, for a `{` or `&` (`${C}:hover,` then `${C}:focus {`), stopping at a `;` or `}` (a declaration value continued after `,`, such as `${prop}: a,` then `b;`) or a blank line. Filled with `&` plus spaces (nothing for an empty placeholder), except when joined to name characters or right after `.`, `#`, `&`, or `:` (`&.${className}:hover {`), where it is part of a name: x's. +- At-rule condition: in an `@media`, `@supports`, or `@container` prelude that starts the statement (at the start of the placeholder's own line, or, for a prelude split over lines, right after the last `;`, `{`, or `}` at code before it, in both cases after any whitespace, block comments, and `//` comments; an unterminated block comment hides the at-keyword), a placeholder separated by whitespace and comments, line breaks included, from a preceding `and`, `or`, or `not`, right after `@supports`, right after the container name (`@container card ${query}`), or right after `@media` and followed by a separate `or` (which follows only a condition, never a media type: `@media ${a} or ${b}`) stands for a condition. Filled with `(x`, spaces, and `)` in `@media` and `@container`, and with `x(`, spaces, and `)` in `@supports`, which the parser reads as a function-shaped supports condition (the plain fill for a placeholder shorter than 3 characters). Otherwise, right after `@media` or `@container`, the x-filled placeholder already reads as a media type or a container name. +- Hex color: right after `#`. Filled with `000` plus spaces (the plain fill for a placeholder shorter than 3 characters). +- Every other placeholder is a value: x's, or `0`s right before `%`. + +## Virtual document + +- Contents: wrapper line, substituted template text with JavaScript escapes replaced (below), then `\n}`. Wrapper is one of: + - `@keyframes custom {\n` when the tag name is `keyframes`. + - `:root{all:\n` (the value wrapper) when the tag name is `css` and the substituted text is value-shaped, such as `` css`1px solid red` `` or `` css`${fadeIn} 1s linear` ``: it validates the text as the value of `all` (a known property, so the wrapper line reports nothing of its own; `vscode-css-languageservice` does not check a value against its property's grammar). Value-shaped means all of: no `;`, `{`, or `}` outside strings, comments, and unquoted `url(...)` arguments; no `:` outside strings, comments, and parentheses; some non-whitespace text; the trimmed text is not a single identifier, escapes included (`` css`dis` `` is a property name being typed, which needs property completions); and the first non-whitespace character does not start a selector or at-rule (`&`, `>`, `+`, `~`, `*`, `[`, `@`, or a `.` not followed by a digit). The test jumps between the characters that can change its answer (the boundary scanner's own, `;`, `{`, `}`, `:`, and parentheses) and stops at the first structural one. + - A `css` fragment that misses value shape only because its trimmed text is a single identifier (`` css`red` ``, `` css`none` ``, `` css`${x}px` ``, `` css`colr` ``) reads as a value as much as a property name, so it keeps `:root{\n` for completions, and validation reports only the diagnostics both readings report: the template is validated a second time under the value wrapper, and a diagnostic is kept only when the value reading has one with the same code, message, and range (both wrappers are one line, so a range past the wrapper names the same template span in either). The wrapper's own ambiguity (`colon expected`, `semi-colon expected`) is therefore silent. The validation cache stores the kept list. + - `:root{\n` otherwise. +- Reading: everything besides the raw text that shapes a template's document and its diagnostics: the wrapper, whether the template's top level is a nested position for the `@layer` rewrite (below), and whether a `css` template is a single identifier (the double validation above). All three are pure functions of the tag kind and the raw template text; nothing else enters (no configuration, no provider state), because the substituted text and its escape replacement are themselves pure functions of the raw text (the placeholder spans are the `${...}` positions in it). Tag kind: `keyframes`, `css`, a global-style tag (`createGlobalStyle`, `globalCss`, `injectGlobal`), or any other tag. So the same raw text under two tag kinds can read differently (`@layer x { color: red; }` in `styled.div` and in `createGlobalStyle`; `colr` in `css` and in `styled.div`). `getReadingKey` names the tag kind as a string; the validation cache key and `canReuseVirtualDocument` (both in the "Caching" section) use it with the raw text and never compute the reading. The reading is computed only when a document is built or an offset translation needs the wrapper, and kept per `TemplateContext` and for the last (tag kind, raw text) pair, so a fresh context for unchanged text reuses it. +- Line map comes from a line-start table computed once from `context.rawText` (the original, unsubstituted template source), never from the substituted text and never from `TemplateContext.toPosition`/`toOffset`: template line L, character C is virtual line L + 1, character C. Virtual line 0 is the wrapper. The table uses TypeScript's own line-terminator rules (`\r\n`, a lone `\n`, a lone `\r`, U+2028, U+2029; `docs/tsserver-host.md`). Built once per `TemplateContext` as a `TemplateLineMap` (`createTemplateLineMap`, `src/virtual-document/template-line-map.ts`, which keeps it per context, so the document and the session provider share one table) and threaded through every position a feature translates, not recomputed per diagnostic, hover, folding, or code-fix result: translating a large result set stays linear in the result count. `VirtualDocumentSessionProvider.getDocument` and `getParsedDocument` return it with the document. The same module maps a virtual range back to template offsets (`fromVirtualDocSpan`, `fromVirtualDocSpanStrict`) under the rules below; features take their offsets from these and never call `TemplateContext.toOffset`. +- One boundary scanner (`nonCodeEnd`, `src/virtual-document/css-code-scanner.ts`, with the character predicates every reader shares) decides what is comment, string, escape, or code for these scans, so they never disagree: separator normalization and the `@layer` rewrite below, the value-shape test above, the stray closing brace search in diagnostics, the statement rule's look-back, the statement boundaries that start an at-rule condition's statement and the custom-property test's text, the selector-shape search in substitution, the nearest non-whitespace character and the joined name after a placeholder, the `{` that opens a rule body on the next line, and the at-keyword look-back, caret placement, and at-rule prelude test in completions. Those three substitution scans (the nearest non-whitespace character and the joined name after a placeholder, and the `{` that opens a rule body on the next line) skip whole comment runs, never strings or unquoted `url(...)` arguments, which are significant content: a comment between a placeholder and the character that decides its role never hides that character (`${Child} /* note */ { color: red; }` is a selector, `${p} /* note */: red;` a property name, and a comment alone on the line before a rule body still opens it). The line rule, and the hex-color and custom-property `--` tests of the text before a placeholder, still do not skip comments or strings; the at-rule condition's at-keyword test skips only the whitespace and comments that open its statement. The scanner's rules follow `vscode-css-languageservice` 6.3.10's own SCSS scanner and the parser that drives it (`Scanner.comment`, `SCSSScanner.comment`, `Scanner._escape`, `Parser._parseURILiteral`), reading U+2028 and U+2029 outside strings as the `\n` the virtual document turns them into: + - A block comment runs from `/*` to the next `*/`, or to the end of the text when unterminated. + - A `//` line comment (SCSS) ends at the next `\n`, `\r`, `\f`, U+2028, or U+2029. + - A `'` or `"` string runs through its matching quote, or ends before an unescaped `\n`, `\r`, or `\f`. A backslash escapes one character, or a line break (a `\r\n` pair counts as one), or up to six hex digits plus one optional space, tab, or line break after them. A quote inside a comment opens no string. + - Outside strings and comments, a backslash escapes the same way (up to six hex digits plus one optional space, tab, or line break, or else one character) except before a line break, where it escapes nothing, and at the end of the template text, where it also escapes nothing because the virtual document follows the text with a line break. An escaped character is identifier text: an escaped quote opens no string, an escaped `/` opens no comment, an escaped brace, `;`, or `@` is not structural, and the escape joins the identifier it starts or continues (`\41 url` is one identifier). + - An unquoted `url(...)` argument opens where an identifier whose name, with escapes decoded and compared case-insensitively, is `url` or `url-prefix` is followed directly by `(`, as the CSS parser reads one: `\75 rl(` opens one, `image-url(` and `#url(` do not. The parser reads the argument as trivia (whitespace and block comments), one token, then trivia again, with `//` comments off (`SCSSScanner.comment` tests `!this.inURL` for `//` only). So inside it: + - A `/*` opens a block comment only where a token can start: right after the `(`, right after a string or another comment, or after whitespace that no escape took (`url(/* x */a.png)`, `url(a.png /* x */)`). Next to url characters, `/` and `*` are url content (`url(a/*x*/b.png)` is one url). + - `//` opens no comment. + - An escape steps over its own characters, so an escaped `)` does not close the argument; an unescaped `)` does. + - Every structural reader skips the argument the way it skips a string: a `;`, `{`, or `}` in it (`url(a;b.png)`, `url(a/*;*/b.png)`) ends no statement, opens or closes no block, and makes a `css` fragment no less value-shaped. The look-back reads the argument as one significant character at its `(`, so a placeholder or caret inside it follows the `(`. + - Not modeled: the url token ends at whitespace that follows url characters (`url(a b)`), where the parser reports an error; the argument stays open here until the `)`. + - `\f` ends a string or `//` comment here but starts no new line for position mapping (the line map above). + - A look-back query at the end of a string or comment with no closing delimiter (a string cut off by a line break or the end of the text, an unclosed block comment, a line comment the text ends inside) is inside it: a caret there still types into it. A query at the end of a closed one is after it. +- JavaScript escapes: the template text is the raw source between the backticks, while styled-components (v6 and v7) receives the cooked strings, each escape already resolved to its character (`interleave` in `packages/styled-components/src/utils/interleave.ts` reads the `TemplateStringsArray` elements, never `.raw`). Each run of adjacent escape sequences becomes a same-length stand-in for its cooked text (`replaceJavaScriptEscapes`, `src/virtual-document/javascript-escapes.ts`) before every other scan in this section reads the text (the value-shape test, separator normalization, the `@layer` rewrite, and the stray closing brace search in diagnostics). Substitution replaces them in its masked text before any decision (the "Substitution invariants" section). After substitution, `getTemplateCssText` (same file) computes the replaced text once per `TemplateContext`, the one source for the virtual document, the value-shape test, and the stray closing brace search; completions read the virtual document, so they see the same text: + - The cooked text is written right-aligned in the run's span, with spaces before it (`\"x\"` reads as ` "x "`, `\\` as ` \`). A cooked control character (below U+0020, and U+007F) or U+2028 or U+2029 is written as a space, so `\n`, `\t`, and a line continuation (a backslash before a line break, cooked to nothing) become spaces, except for padding inside an unquoted `url(...)` argument (below). + - When the cooked run ends with a backslash that escapes what follows (an odd count), the padding goes inside that CSS escape instead of before it: as leading zeros before the hex digits that follow (`"\\f101"` reads as `"\0f101"`), while the zeros and digits together stay within six. The CSS escape reads on over hex digits from later runs too: when a later run's cooked text continues those digits, that run joins the stand-in and the zeros go after the backslash for the whole escape (`"\\4\x31"` reads as `"\000041"`); outside an unquoted `url(...)` argument, when the joined zeros and digits would pass six, each run keeps its own stand-in (`x\\fa\x34;` reads as `x\0fa\034;`). Otherwise as the escaped character's own hex code, padded to fill the room (`u\\rl(` reads as `u\72l(`), when the code fits and the character after it is none of a hex digit, whitespace, a backslash, or the end of the text. + - Otherwise, inside an unquoted `url(...)` argument, where a space would end the url token, every run's padding is `_`, after its cooked text and before a trailing escaping backslash unless placed as below, and none of the name rules below apply: `url(a\\(b)` reads as `url(a_\(b)`, `url(a/\x41y.png)` as `url(a/A___y.png)`, and a line continuation (`url(foo\` then a line break, then `bar.png)`) as `url(foo__bar.png)`. The stand-in holds no line break there, which position mapping allows (the line map below counts the raw one). Placement inside the argument: + - The `_` goes before a `)` in the cooked text that closes the argument, and before CSS whitespace that ends the url token, so it never lands outside the argument or forms a second token: `url(a.png\x20)` reads as `url(a.png___ )`. A `)` a cooked backslash escapes, and whitespace a CSS escape takes (after a backslash, or one after a hex escape's digits), is url content: `url(\x5c\x31\n\\f)` reads as `url(\1 ________\f)`. + - A run that cooks to whitespace alone at the argument's start is leading trivia, padded with spaces (`url(\x204a)` reads as `url( 4a)`); right after a hex escape a previous run left open, the whitespace comes first, so the escape takes it, and the `_` follows. + - A cooked quote as the argument's first content makes the argument a quoted string: the run is padded as outside a url (`url(\x22\x22)` reads as `url( "")`). + - The argument is found by reading back from the run over url characters to the `(` of a `url` or `url-prefix` function, with whitespace allowed only right after that `(`; a quote, a paren, or other whitespace means no url, unless a CSS escape takes it (a cooked backslash right before it, or one whitespace after a hex escape of at most six digits: `url(a\\"b\x41)` and `url(a\\41 b\x41)` stay one url). A url function name must be written literally: the name is the run of name characters before the `(` in the raw text (the name characters defined above), so a name character before `url`, a non-ASCII letter included, makes a different function name (`äurl(`, `image-url(`), and so does a `#`, `@`, or backslash before it; a name spelled with an escape inside it or joined to an escape run before it (`u\x72l(`, `\x75rl(`, `u\\rl(`, `\x20url(`) opens no url here, and the runs in its argument are padded as outside one. Inside the argument, a run whose cooked text leaves a CSS hex escape open takes that escape whole (the hex digits after it, raw or from later runs, which then join its stand-in), and its padding goes as leading zeros after the backslash while the escape stays within six digits, or else as `_` before the backslash, never between the digits and the whitespace the escape takes: `url(a\\4\x31 b)` reads as `url(a\000041 b)`, and `url(a\\\x34\x31 b)` as `url(a_______\41 b)`. Each run's look-back stops where the previous run ended and takes that run's answer, unless its cooked text holds a character no CSS escape takes that ends the url token (CSS whitespace, a quote, or a paren) or that the stand-in writes as a space; a character that only JavaScript counts as whitespace (U+00A0, U+3000, U+FEFF) is url content. So the text is read once. + - Inside a name, spaces between the name and the run would split it, so when the stand-in starts with a name character or a CSS escape, or is exactly `(` right after a name (url() takes no whitespace before its `(`: `url\x28a)` reads as ` url(a)`), the run continues the name before it: name characters read back from the run (to where the previous stand-in ended), plus one leading `#`, `.`, `@`, `:`, or `!`. When the character before that name is whitespace, `;`, `{`, `}`, `(`, `,`, or the template start, the padding moves in front of the whole name and the name reaches the CSS service spelled as it is cooked: `co\x6Cr` reads as ` colr` (one unknown property, `colr`), `ba\x63kground` as ` background`, `r\x65d` as ` red`, `.md\\:flex` as ` .md\:flex`. A later run in the same name adds its padding to the same spaces (`\x63ol\x6Fr` reads as ` color`). + - Otherwise, where whitespace in front of the name would split a larger token (`&.bt\x6E`, `a:ho\x76er`, `color:r\x65d`), the last cooked character becomes a CSS hex escape filling the padding, followed directly by the text after the run when that character is none of a hex digit, whitespace, a backslash, or the end of the text (`&.bt\06e{`), and ended by one space otherwise (`&.bor\64 er`, since the CSS scanner absorbs one space after a hex escape). The CSS service reads the escaped name as one identifier; a property name spelled with a CSS escape is checked as written, which is why the move in front comes first. + - The padding stays spaces before the cooked text, splitting the name, when neither applies: the name is a hash or starts with a digit, where an escaped digit would start an identifier (`color:#f\x66f`, `margin:1\x30px`); the code does not fit six hex digits (a character outside the Basic Multilingual Plane after `&.a`); the cooked text ends with a CSS escape. A stand-in that starts with any other character (`colr\x3a red`) keeps its spaces between the name and the run, so an edit to the name alone does not touch the run. + - A cooked line break inside a `//` comment is a space here, so the comment runs to the real end of its line and hides the rest of that line from validation. + - An invalid escape (`\1`, `\x4`, `\u{110000}`, cooked to `undefined` at runtime) stays as written. +- U+2028 and U+2029 become `\n` outside quoted CSS strings (the CSS scanner does not treat them as whitespace) and stay verbatim inside strings. Replacement is one character for one character. +- Nested block `@layer` workaround: vscode-css-languageservice's SCSS parser parses a block `@layer` inside a style rule without its nested flag, so a declaration directly in the layer body (`@layer utilities { color: red; }`) reports a false `{ expected` (https://github.com/microsoft/vscode-css-languageservice/issues/530). The virtual text rewrites each block `@layer` prelude that sits in a nested position (the `@layer` keyword in any letter case, as the parser matches keywords, plus an optional single layer name of name characters, dotted names and the anonymous form included, up to its `{`) to spaces with one `&` just before the `{`, so the parser reads a nested `& { ... }` rule with the same body. Nested position is any brace depth inside the template, plus the template's own top level, except when the tag is a global-style tag (`createGlobalStyle`, `globalCss`, `injectGlobal`), whose top level is the stylesheet's top level; which of the two the template's top level is belongs to its reading (above). A comment in the prelude separates tokens as whitespace does and is blanked with the rest of the prelude (`@layer /* note */ base {`). Statement-form `@layer a, b;`, a list prelude (`@layer a, b {`), a prelude holding an unterminated comment, and a prelude holding any other character (a string) stay untouched. The same string, comment, and `url(...)` aware scan as the separator normalization does both jobs in one linear pass that jumps between the characters either job can act on (the boundary scanner's own, `@`, braces, and the separators) and copies each stretch between them unchanged, skipped when the text has neither separator nor `@layer`. Output length equals input length. Remove this rewrite once the plugin depends on a vscode-css-languageservice release containing the upstream fix. +- Mapping back to the template: + - diagnostics, hover, folding: a position on the closing `\n}` clamps to the template end, so end-of-template syntax errors (unclosed block, truncated declaration) are reported; a position inside the opening wrapper is dropped. + - a hover without its own range: the one character at the caret, or an empty span when the caret sits at the template end, so the span never covers the closing backtick. + - code-fix text edits: any position outside the template body is rejected, because the wrapper does not exist in the source file. + - Escape runs: the span of raw text each run of JavaScript escapes and its stand-in cover (the escaped character after it included when the stand-in took it, the hex digits and later runs a joined CSS hex escape takes, and the name characters before and between the runs when the padding moved in front of the name), computed in the same pass as the replaced text (`getTemplateEscapeRuns`) and carried on the `TemplateLineMap`, which computes them on first read and only when the raw text holds a backslash (no placeholder fill holds one, so without one in the raw text there are no runs). That first read calls back into the `TemplateContext` the `TemplateLineMap` was built from (`getTemplateEscapeRuns` reads `context.text`), even when a reused line map (the "Caching" section) answers a later, different context's request: safe only because `VirtualDocumentSessionProvider.ensureDocument` builds a `TemplateLineMap` solely alongside `createVirtualDocument` for that same context, and `createVirtualDocument` already reads that context's substituted text (`getTemplateCssText`, which shares `getTemplateEscapeRuns`'s own per-context cache in `javascript-escapes.ts`), so the escape-runs read always hits that cache instead of substituting again. A caller that could obtain a `TemplateLineMap` without `createVirtualDocument` having already run for its own building context would read a stale context's `.text` there. A diagnostic, hover, folding, or completion replacement span whose start or end falls strictly inside a run widens to cover the whole run (`\x63olr` is underlined whole, not `3olr`). A code fix is dropped when any of its edits overlaps a run, or, when empty, sits strictly inside one: the edit is written for the stand-in, so applied to the escape as written it corrupts it (renaming `\x63olr` to `color` would write `\x6color`). An edit that only touches a run's edge is kept. + +## Completions + +- A completion list merges, in order: CSS items, SCSS items filtered to pseudo-class functions, nested at-rules (below), then Emmet abbreviations. +- Caret placement, decided on the virtual text (wrapper included) with the boundary scanner (the "Virtual document" section): + - Inside a comment or string, the end of one with no closing delimiter included: no completions at all. The CSS language service offers nothing useful there: none inside a value string, and only selector names inside an attribute-selector string. + - No declaration can start: the last structural code character before the caret (`;`, `{`, `}`, or `:`) is `:` (a value, a selector with a pseudo-class such as `&:hover m10 {`, or a media feature such as `@media (min-width: m10)`), or the statement that character ends, past whitespace and comments, starts with `@` (an at-rule prelude such as `@media m10 {`). The value wrapper of a value-shaped `css` fragment counts, so its whole text is a value. Emmet items that expand to a whole declaration (their label holds a `:`, such as `float: left;` for `fl`) are dropped, because accepting one inserts a declaration where none fits. Value expansions (`#121212` for `#12`, `!important`) stay. + - Anywhere else, including an empty template and a line typed above a nested rule (`m10` before `&:hover {`, where a declaration can start): every source above. + - Cost: the structural search runs only when an Emmet item expands to a declaration, and costs at most two passes over the text before the caret (one in the common case, where the nearest structural character is code), plus the whitespace and comments that open the caret's statement. +- Nested at-rules: `vscode-css-languageservice` offers no at-rules inside a style rule (CSS mode returns none; SCSS mode returns only Sass directives, which the SCSS filter drops). When the caret ends or sits inside an at-keyword (`@` and the name characters around the caret, the set in the "Substitution invariants" section, as the CSS scanner reads one: `@`, `@me`, `@mé`) that is code (not inside a comment or string) and whose preceding significant character in the template, past comments, is `;`, `{`, `}`, or the template start, the list adds the at-rules valid nested in a style rule (`@media`, `@supports`, `@container`, `@layer`, `@scope`, `@starting-style`) and the ones styled-components hoists to the top level (`@keyframes`, `@property`, `@font-face`, `@font-palette-values`, `@page`, `@counter-style`), skipping a label the list already has. + - Inclusion rule for hoisted at-rules: one the styled-components v7 emitter writes at the stylesheet's top level, outside the component's selector, through either of its two branches in `packages/styled-components/src/parser/emit-web.ts` (an undocumented internal): a name in `DECL_BODY_AT_RULES`, whose body `emitAtRule` writes as bare declarations, or a keyframes name, which the parser reads into its own keyframes node (`isKeyframesName`, `packages/styled-components/src/parser/atRuleNames.ts`) and `emitKeyframes` writes as keyframe blocks of declarations; whose body v7 keeps as the author wrote it; and that the CSS language service completes at the top level. Applied to each candidate: + - `@font-face`, `@page`, `@property`, `@counter-style`, `@font-palette-values`: the declaration branch; bodies of declarations, kept; completed. Included. + - `@keyframes`: the keyframes branch; keyframe blocks, kept; completed. Included. + - `@color-profile`, `@viewport`: the declaration branch, but the CSS language service does not complete them. Left out. + - `@font-feature-values`: the declaration branch, but its body holds feature-value blocks (`@styleset`, `@swash`), which `emitAtRule` drops because it writes only declarations. Left out. + - Vendor-prefixed keyframes (`@-webkit-keyframes`, `@-moz-keyframes`, `@-o-keyframes`): the keyframes branch, and they pass the rule, but they are left out: legacy prefixed syntax is not worth supporting (AGENTS.md, "User experience"), and the unprefixed `@keyframes` is offered. + - Name, kind, and documentation come from the CSS language service's own top-level at-rule completions (the catalog); a name absent from the catalog is not offered. + - The replacement span covers the whole at-keyword at the caret. +- Every entry is translated the same way: name, kind, kind modifiers, sort text, replacement span; never insert text, a snippet flag, or filter text. +- Entry details for a name the list at that position does not hold (and the recovery fallback, the "Exception recovery" section): the name as the only display part, kind unknown, no kind modifiers, documentation, or tags. + +## Diagnostics + +- Validation: the SCSS service's `doValidation` over the virtual document, intersected with the value reading for a single-identifier `css` fragment (the "Virtual document" section), memoized by `RawValidationCache` (the "Caching" section). With `validate: false`: no diagnostics and no code fixes. +- Shown list (`DiagnosticsFeature.getShownDiagnostics`), the one list diagnostics and code fixes both read: each raw diagnostic mapped to template offsets under the diagnostics rule in "Mapping back to the template" (the "Virtual document" section: clamped at the closing wrapper, dropped inside the opening one, widened to whole escape runs), then the stray closing brace rule below. +- Translation: the diagnostic's own `code` when numeric, `CSS_DIAGNOSTIC_CODE` (9999) otherwise; severity Error or none as Error, Warning as Warning, Information and Hint as Message; `source` is the plugin identity. +- Stray closing brace: `css-ruleorselectorexpected` ("at-rule or selector expected") at the template end reports the wrapper's own closing brace, which the template's content closed early. + - Causes: a stray `}`, or an unattached `;`. An empty template reports nothing. + - Always kept: never dropped because another diagnostic exists in the template (an unrelated lint warning, or one from the brace's own cascade). + - Re-anchored to the stray `}`: the first `}` in the template's CSS text (`getTemplateCssText`) with no matching opener, read with the boundary scanner, so a `}` in a string, comment, escape, or unquoted `url(...)` argument does not count; no placeholder fill holds a brace. The span is that one character. + - No stray `}` (an unattached `;`): stays at the template end, zero length. + - The same code anywhere else in the template is a real diagnostic and stays where reported. Other end-of-template diagnostics (`} expected`, `property value expected`) carry other codes and stay. + +## Code fixes + +- Supported codes: `CSS_DIAGNOSTIC_CODE` only, a constant that builds no language service. A request whose error codes omit it gets no fixes. +- A request asks about the shown diagnostics (the "Diagnostics" section) whose template span holds its caret (an empty request; start inclusive, end exclusive) or shares at least one character with its selection. None asked about: no fixes and no CSS service call. So a diagnostic the single-identifier intersection drops gets no fix, and a request after a diagnostics request is answered from the validation cache. +- The asked-about raw diagnostics go to the SCSS service's `doCodeActions` with the request's range mapped into the virtual document. Only `_css.applyCodeAction` commands whose third argument is an array of text edits become fixes; each edit maps under the code-fix rule in "Mapping back to the template", and a command with any unmappable edit is dropped whole. + +## Exception recovery + +- Every `StyledTemplateLanguageService` entry point runs inside one `recover` wrapper, so a throw never reaches tsserver: the decorator forwards each result straight into the response for the whole file, TypeScript's own diagnostics included. +- On a throw: one line ` threw and was recovered: ` through the plugin logger (the tsserver log; a service constructed with a language service factory instead of a logger writes none), then the fallback: + - completions: an empty completion list, a fresh object per call. + - completion entry details: the unknown-entry details (the "Completions" section). + - quick info: none. + - diagnostics, code fixes, outlining spans: an empty list. +- `getSupportedCodeFixes` returns its constant and cannot throw. +- A recovered throw leaves the service answering later requests. + +## Caching + +- One cached virtual document and parsed stylesheet per service, reused only when `canReuseVirtualDocument` holds: same file name, same raw (unsubstituted) template text, same tag kind (the "Virtual document" section, "Reading"). Raw text, not substituted text: two placeholders of equal length can substitute identically while having different line maps. Same tag kind, because the same raw text under another tag kind can read differently; the reading is a pure function of the two, so the check computes neither the reading nor the substituted text. +- A request answered from a cache (a validation hit, a reused document or completion result) never reads `TemplateContext.text`, which the decorator substitutes lazily, once per context, and every request brings a fresh context: an empty-template test or a template-end bound reads `rawText.length` (substitution keeps the length). `test/unit/template-language-service.test.ts` ("substitution on unchanged templates") counts substitutions to guard it. +- A reused document's `positionAt`/`offsetAt` never call back into the `TemplateContext` that built it, only the line-start table above: `TemplateContext.toPosition`/`toOffset` are memoized by the decorator against the template's position in the file at the moment that context was constructed (`StandardTemplateContext`, `typescript-template-language-service-decorator`), so a request that edits the file _outside_ the template moves the template without invalidating a reusable cache entry, and a document that kept consulting the old context would map to the wrong span for the rest of that session. +- Completions cache the last result per position with the same reuse rule, both the raw CSS items and the translated tsserver entries, and clear on configuration change. +- The nested at-rule catalog (the "Completions" section) is requested once per service on first use and kept for its lifetime: it does not depend on configuration. +- A third-party `VirtualDocumentProvider` without `canReuseVirtualDocument` gets no reuse. +- Diagnostics additionally memoize raw (untranslated) `vscode.Diagnostic[]` validation results across every template in a file, not just the one-entry virtual-document cache above. `RawValidationCache` (`src/features/diagnostics.ts`) is a least-recently-used map keyed by the tag kind (`getReadingKey`, the "Virtual document" section, "Reading") plus the template's own raw text. A file with many templates otherwise re-parses and re-validates every template on every request, because the one-entry cache above is evicted by the next template before the same template comes around again. +- The validation cache is bounded by both an entry count (`MAX_VALIDATION_CACHE_ENTRIES`) and an estimated total byte size (`MAX_VALIDATION_CACHE_BYTES`), whichever binds first. An entry's estimated size is its key length plus, per diagnostic, its message length (both counted as UTF-16 code units times 2 bytes) plus a fixed per-diagnostic overhead constant (`VALIDATION_CACHE_DIAGNOSTIC_OVERHEAD_BYTES`). An entry estimated larger than the byte budget alone is never cached. `corepack yarn test:scaling` guards the byte budget with a long edit session through a realistic multi-kilobyte, many-diagnostic template. +- The validation cache stores only raw vscode diagnostics, never translated `ts.Diagnostic`s (which hold `file: SourceFile` and must not outlive the request). Reuse is trusted only for the built-in `StyledVirtualDocumentProvider` (checked by `instanceof`, not by a third-party object merely defining a same-named `canReuseVirtualDocument`, which this cache never calls), because only that provider's virtual text and validation are verified as a pure function of the tag kind and the raw text. A hit builds no document and computes no reading. It needs a line map only when it holds a diagnostic to map (an empty hit maps nothing): the session's line map when the session's cached document is reusable for that template, and a fresh line map otherwise. The cache clears on configuration change because lint settings change validation results. +- The validation key length-prefixes the tag kind key before the raw text (`buildValidationCacheKey`, `src/features/diagnostics.ts`), so two different (tag kind key, raw text) pairs cannot concatenate to the same characters. +- A stored key is copied so it references none of the open file's text (`flattenCacheKey`), only when the cache stores it (`set`). `context.rawText` is a slice of the whole open file's text, and V8 represents a slice of a long string as a view into its parent rather than a copy; the copy is a near-free defense against depending on that unspecified behavior (`node --expose-gc test/performance/validation-cache-key-flattening-probe.mjs` verifies it). A lookup (`peek`) compares the caller's unflattened key against stored keys by content, then re-inserts under the matching entry's already-flattened key on a hit. +- Flattening uses pure UTF-16 concatenation and slicing, not a Node-only `Buffer`: this file is reachable from `./api`, which a bundler could ship to a browser, and a UTF-8 round trip would rewrite an unpaired surrogate to U+FFFD, colliding two templates that differ only in one. + +## Configuration + +- Source: `compilerOptions.plugins[]` entry at startup, then `configurePlugin` requests. `PluginConfigurationManager` (`src/configuration/plugin-configuration.ts`) normalizes any input shape and never throws, including when its logger throws. +- Normalization per field: + - `tags`: a list of strings, or one string as a one-item list. Anything else is rejected. + - `validate`: a boolean, or the string `"true"` or `"false"`. Anything else is rejected. + - `lint`, `emmet`: an object; anything else is rejected. Inside `lint`, a level setting (every key of `StyledPluginLintConfiguration` except `validProperties`) takes `"ignore"`, `"warning"`, or `"error"`, plus the aliases `"off"` (as `"ignore"`) and `"warn"` (as `"warning"`), each in any letter case (`"Error"` as `"error"`); another value is rejected and the key dropped, so a key with a plugin default (`defaultConfiguration.lint`, such as `emptyRules`) keeps that default, and any other key gets the CSS language service's own default for that rule. The log message names the plugin default where one applies. `validProperties` takes a list, keeping its string entries and rejecting the rest, or one string as a one-item list. Unknown `lint` keys and every `emmet` key pass through unchanged. + - A rejected value falls back to its default and logs one message to the tsserver log naming the setting, the accepted form, and the value received. An accepted alias or letter-case variant logs nothing, and neither does an absent field or a `null` or `undefined` top-level configuration. A field set to `null` is rejected. + - A top-level key that is not one of the fields above is ignored and logs one message naming it and those fields, plus a "Did you mean" suggestion: the setting nearest by edit distance with letter case ignored, when that distance is at most 40% of the key's length (at least 1), so `tag` suggests `tags` and `validation` suggests `validate`. The keys tsserver adds to the configuration it passes (`name` and `global`, docs/tsserver-host.md) log nothing. +- Each update replaces the whole configuration (fields absent from an update return to defaults), so a `configurePlugin` request replaces the `tsconfig.json` settings rather than merging into them, as in 1.0.1. +- Defaults (tags, lint levels) live in `PluginConfigurationManager.defaultConfiguration`; user-facing documentation in `docs/usage.md`. + +## Public surface + +- Package root: the tsserver plugin factory only, at `lib/index.js` (1.0.1's exact `main` path), a CommonJS bundle. tsdown builds it as `lib/index.cjs` (`cjsDefault` turns `export default createPlugin` into `module.exports = createPlugin`) and a `build:done` hook renames it to `lib/index.js` and writes a nested `lib/package.json` (`{"type": "commonjs"}`) so Node and every supported TypeScript version's classic plugin resolution treat it as CommonJS despite the package root's own `"type": "module"`. The rename matters because tsserver always loads plugins by reading `main` and resolving it with classic Node10 `require()`, never through the `exports` map: a `.cjs`-suffixed main fails to resolve on TypeScript 4.4 and older (docs/tsserver-host.md), the same class of gap already recorded there for a `.mjs` main. The single `exports["."]` condition (`types` plus `default`, no `import`/`require` split) serves this same file to both a `require()` caller and an `import` caller: Node's ESM loader synthesizes the default export from `module.exports` for the latter. Types are `lib/index.d.cts` (and a copy at `lib/index.d.ts`, see below), tsdown-generated from `src/index.ts` using `export =`, which every supported TypeScript version (5.0 through current) parses; a `.cts` consumer reads it as `import plugin = require(...)`, an ESM consumer reads it as a default import. +- Deep subpaths 1.0.1 resolved without any `exports` map keep resolving through explicit entries: `./lib/index`, `./lib/index.js` (both redirect to the root's `lib/index.js` runtime and `lib/index.d.cts` types), `./lib/api`, `./lib/api.js` (both resolve to the physical `lib/api.js` runtime and `lib/api.d.ts` types described below), and `./package.json`. tsserver's own plugin loader only ever reads `main` for `.`, never these subpaths or the `exports` map at all, so they exist purely for a consumer's own `require()`/`import()`. Unlike `./api`, these two subpaths carry one `{ types, default }` mapping with no `import`/`require` split, because 1.0.1 shipped `lib/api.js` as a plain CommonJS file: a `require()` and an `import x from` of it both got the whole CommonJS exports object back, a shape a real ES module cannot reproduce (it never synthesizes a default export, so a consumer default-importing one gets TS1192). `lib/api.js` and `lib/api.d.ts` are real files, not only `exports` entries, so a resolver that ignores `exports` entirely (webpack 4, Jest before 28, the `resolve` package's default configuration) still finds them by walking `node_modules` for a file at the literal requested path; each is a thin CommonJS re-export of the `./api` build below (`module.exports = require('./esm/api.cjs')`; `export * from './esm/api.cjs'`), written by the `build:done` hook in `tsdown.config.ts`, so this deep subpath and `./api`'s require condition share one `module.exports` object instead of shipping the compiled bundle twice. `test/package-api/runtime.ts` loads this file by its absolute path in the extracted tarball, bypassing `exports` the same way, to prove it is a real file rather than only an `exports` redirect target. +- `@styled/typescript-styled-plugin/api` (import and require): `StyledTemplateLanguageService`, `PluginConfigurationManager` (and its `ConfigurationManager` alias, matching 1.0.1's class name), `getTemplateSettings`, and the configuration and provider types listed in `src/api.ts`. Removing or narrowing anything here is breaking and ships in a `major` changeset. Runtime is a real dual build: `import` gets `lib/esm/api.mjs`, `require` gets `lib/esm/api.cjs`, a genuine CommonJS bundle (not a stub) generated from the same `src/api.ts` by the same tsdown config's CommonJS format, so `require('@styled/typescript-styled-plugin/api')` works on every Node version this package supports, not only ones with `require(esm)` support; `./lib/api` and `./lib/api.js` (above) always resolve to this same CommonJS bundle, regardless of import or require syntax. Types mirror the runtime split: `import` gets `lib/esm/api.d.mts` (tsdown's normal ESM dts pass), `require` gets `lib/esm/api.d.cts` from the CommonJS format, which never imports from the ESM-only `api.mjs` (the cross-module-system import is what TS1479 flags on TypeScript 5.0 through 5.7) and cannot drift from the source the way a hand-kept copy could. Neither `./api` bundle loads `typescript-template-language-service-decorator` at runtime: `getTemplateSettings` lives in `src/template-settings.ts`, apart from the tsserver entry that calls the decorator, and the decorator's `TemplateSettings` type reaches the declarations only. +- `moduleResolution: node10` ignores `exports` entirely, so a node10 consumer's deep import instead needs either a real file at the literal requested path or a `typesVersions` redirect. `api` uses `typesVersions` (`package.json`) to redirect to `lib/esm/api.d.cts`; `lib/api` and `lib/api.js` need no redirect, since node10's file-based lookup finds the physical `lib/api.d.ts` next to `lib/api.js` (above). `lib/index` and `lib/index.js` cannot use the same mechanism, because `typesVersions` also reaches the plugin loader itself for a subpath that equals `main` (docs/tsserver-host.md): `lib/index.d.ts` is instead a real, physically-copied file (mirroring 1.0.1's own `lib/index.d.ts` filename) that node10's default file-based lookup finds without any redirect. +- Emitted declarations are contract: `test/package-api` type-checks the root entry (both a `.cts` `import = require(...)` consumer and an ESM default-import consumer), `./api` (both conditions), a consumer written exactly to 1.0.1's published shape (`consumer-1.0.1-shape.cts`: an unguarded `onConfigurationChanged` call, a `StyledPluginConfiguration` with arbitrary `lint`/`emmet` keys, and a `VirtualDocumentProvider` implementation omitting the optional context parameter), and a `moduleResolution: node10` consumer of the deep subpaths (`consumer-node10.cts`), and loads the packed tarball's root, `./api`, and the deep subpaths from both `require()` and `import()`, including a default import of `./lib/api.js` (`test/package-api/runtime.ts`). The type gates run against the minimum and maximum supported TypeScript plus two versions in between, plus the node10 consumer on the current version, all through `yarn test:package-api:consumers` (`scripts/test-package-api-types.ts`), which runs each `tsconfig.consumers..json` (the shared `tsconfig.consumers.json` base excepted) with the TypeScript its `typescript/*` `paths` entry names: that override maps the specifier to that exact version's own install (`node_modules//*`, an alias declared in `test/e2e/package.json`), so the ambient `typescript/lib/tsserverlibrary` types match the compiler running the check instead of whatever `typescript` version happens to be hoisted. Adding a TypeScript version means adding one tsconfig. The node10 gate additionally needs a real `node_modules/@styled/typescript-styled-plugin` self-link at the repository root, which the same runner creates when missing and re-points when it resolves anywhere else, because node10 resolution has no self-reference feature to fall back on the way node16/nodenext/bundler do. A `moduleResolution: bundler` gate (`tsconfig.consumers.bundler.json`) default-imports `./lib/api.js` (`consumer-lib-api-default.mts`, also checked under the default node16-family config above) to prove the CommonJS shape holds under bundler resolution too. +- `StyledPluginConfiguration` is the fully-resolved shape 1.0.1 published under this name (`tags`, `validate`, `lint`, `emmet` all required), with `lint` and `emmet` typed exactly as 1.0.1 declared them, the open `{ [key: string]: any }` shape, not the closed `StyledPluginLintConfiguration` / `StyledPluginEmmetConfiguration` interfaces; those richer, closed shapes live on `StyledPluginConfigurationInput`, the partial shape for building a configuration from individual overrides, and on the standalone interfaces themselves. +- The package root factory's declared return type has `create` and `onConfigurationChanged` both required, with `onConfigurationChanged`'s parameter typed `any`, matching 1.0.1's own `StyledPlugin` class exactly rather than the host's `ts.server.PluginModule` (both members optional there): a non-exported `StyledPlugin` interface local to `src/index.ts` states this contract, and `TsServerStyledPlugin` (`src/tsserver/tsserver-plugin.ts`) already implements both members as required. diff --git a/documentation/preview.gif b/docs/images/preview.gif similarity index 100% rename from documentation/preview.gif rename to docs/images/preview.gif diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..256de09 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,467 @@ +# Maintenance + +This document describes the local workflow for maintaining +`@styled/typescript-styled-plugin`. + +## Requirements + +- The Node.js version in `.github/.node-version` for development and builds. +- Node.js 14.21.3 or newer in the tsserver host at runtime, for both the plugin root and `./api` (docs/tsserver-host.md). +- Corepack and the Yarn version declared in `package.json`'s `packageManager` field. +- Git for contributing changes. + +Clone the repository, enable the package manager when necessary, and install +the locked dependency graph: + +```bash +git clone https://github.com/styled-components/typescript-styled-plugin.git +cd typescript-styled-plugin +corepack enable +yarn install --immutable +``` + +Without `corepack enable`, prefix every command with `corepack` instead +(`corepack yarn install --immutable`, `corepack yarn verify`); the commands +below are written in the shorter form. Dependencies' install scripts stay off +through `enableScripts: false` in `.yarnrc.yml`, so no extra flag or +environment variable is needed. + +The root workspace includes the dependencies used by the tsserver E2E fixtures. +Do not install dependencies inside individual fixture directories. + +## Commands + +| Command | Purpose | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `yarn compile` | Build the CommonJS tsserver entry and ESM API into `lib/`; also generates CJS types for `./api`. | +| `yarn watch:compile` | Rebuild the package while source files change. | +| `yarn pack:artifact` | Build `lib/` (through `prepack`) and pack it to `package-artifact/package.tgz`, the tarball the package checks read. | +| `yarn benchmark` | Measure per-feature latency, cache throughput, and retained-heap slope. | +| `yarn format` | Apply formatting with `oxfmt`. | +| `yarn format:check` | Check formatting without modifying files. | +| `yarn lint` | Run `oxlint`. | +| `yarn lint:fix` | Apply safe lint fixes. | +| `yarn typecheck` | Type-check source, scripts, and test code without emitting files. | +| `yarn test` | Run `test:unit` then `test:e2e`. | +| `yarn test:unit` | Run unit tests in `test/unit`. | +| `yarn test:e2e` | Rebuild and run the Node tsserver scenarios in `test/e2e/scenarios`. | +| `yarn test:e2e:run` | Run E2E scenarios against the existing `lib/` build without rebuilding it. | +| `yarn test:e2e:current` | Rebuild, then run E2E scenarios against the `typescript-current` workspace alias. | +| `yarn test:e2e:minimum` | Rebuild, then run E2E scenarios against the minimum supported TypeScript workspace alias. | +| `yarn test:e2e:5.9` | Rebuild, then run E2E scenarios against the `typescript-5.9` workspace alias. | +| `yarn test:package-api` | Load the packed tarball's root, `./api`, and deep subpaths through `require()` and `import()` (see Packaging below). | +| `yarn test:package-api:consumers` | Type-check the built declarations in every consumer gate, each on its own TypeScript version; name gates to run only those (docs/architecture.md, "Public surface"). | +| `yarn test:scaling` | Fail on superlinear growth, a wrong result, or no real work in a hot operation, or cache memory past its bounds (see below). | +| `yarn test:pack` | Assert the exact file list of the packed tarball (see Packaging below). | +| `yarn verify` | Run formatting, linting, type checking, all tests, and the package checks, building `lib/` once. | +| `yarn compare-release` | Print every diagnostic, completion, hover, fold, and code fix that differs from a published release (see below). | +| `yarn changeset` | Add a changeset describing a pending, noteworthy change (see Releases below). | +| `yarn changeset-credits` | List the git authors and co-authors behind each pending changeset. | +| `yarn changeset-version` | Consume pending changesets: bump the version, write CHANGELOG.md, delete the changeset files. Run only by the release workflow. | +| `yarn changeset-publish` | Build and publish the package to npm. Run only by the release workflow. | + +Run `yarn verify` before opening a pull request. It is the release-oriented +local gate and the closest equivalent to the CI workflow. + +## Running tests + +Only composite commands build: `yarn verify`, `yarn test:e2e` (and its +`:minimum`, `:5.9`, `:current` forms), `yarn pack:artifact`, and every +`yarn pack` through the `prepack` lifecycle. Every other check reads what is +already on disk: `yarn test:e2e:run`, `yarn test:package-api:consumers`, and +`yarn compare-release` read `lib/` (run `yarn compile`), while +`yarn test:package-api` and `yarn test:pack` read +`package-artifact/package.tgz` (run `yarn pack:artifact`). Each of the last +four fails with a message naming that command when its input is missing. +After a source change, rebuild before running one of them alone, since a +stale build passes as current (only `yarn compare-release` compares +modification times). + +`yarn verify` builds once, through `yarn pack:artifact`, and runs every check +that reads `lib/` or the tarball after it. `yarn changeset-publish` builds +once, through the `prepack` lifecycle of the `yarn npm publish` it runs. + +Every `test:e2e*` script rebuilds the same `lib/` directory, so running two of +them at once (checking two TypeScript aliases side by side, for example) races +on that rebuild: one run's `yarn compile` can overwrite `lib/` while the other +run's tsserver instances are loading it, producing a build mismatch rather +than a clean failure. Each run's tsserver logs go to their own directory +(`test/e2e/tsserver-fixture/clear-logs.ts`, keyed by the vitest process's +pid), but the rebuild is still not safe to run concurrently: run one +`test:e2e*` script to completion before starting another. + +Every tool that runs plugin code is bounded so a regression fails it instead +of hanging the machine: the scaling check, the benchmark, and compare-release +run it in a worker thread with a deadline and a heap cap (below); an e2e +request with no answer within `TSSERVER_RESPONSE_TIMEOUT_MS` +(`test/e2e/tsserver-fixture/timeouts.ts`) fails its test and stops that +tsserver at once, and each tsserver runs with a heap cap +(`test/e2e/tsserver-fixture/server.ts`); each unit test worker runs with a heap +cap (`vitest.config.ts`). Vitest's own `testTimeout` cannot stop a test stuck +in a synchronous loop, since its timer runs on the same blocked thread, so +`yarn test:unit` and `yarn test:e2e:run` each run through +`scripts/with-deadline.ts` instead of calling Vitest directly (the deadline +each passes it is set where package.json invokes the wrapper). The wrapper +starts Vitest as the leader of its own process group and, once the run's +wall-clock deadline passes, kills the whole group, including any tsserver +child an e2e test started, then exits nonzero with a message naming what ran, +the deadline, and, when the wrapped command is `vitest`, that a synchronous +loop is the likely cause and how to find it (rerun with `--reporter=verbose` +to see which test last started). A run that finishes in time passes its exit +code and output through unchanged, and the wrapper forwards SIGINT and SIGTERM +to the child group so a cancelled run leaves nothing behind. Override the +deadline with the `WITH_DEADLINE_MS` environment variable for a shorter one +while testing the wrapper itself, or a longer one to keep a debugger attached +past the default. + +Ad hoc probes of `src/` need `node --experimental-transform-types`, not +`--experimental-strip-types`: the source uses TypeScript parameter properties, +which type stripping alone rejects. + +## Performance baseline + +Run `yarn benchmark` to execute +`test/performance/template-language-service.bench.ts` with Node's native +TypeScript transform and explicit garbage collection. It prints a table of +latency and throughput for completions, diagnostics, hover, and folding on +small and large templates, plus a warmed completion-cache lookup. It also +reports two retained-heap slopes, KB retained per iteration between three +settle points, for a realistic request-per-iteration shape, a no-op control +(should read near zero), and a deliberate-leak control (should read clearly +above the no-op control). The command fails loudly if `--expose-gc` was not +passed, instead of printing a meaningless delta, and when any task throws. The +whole run happens in one worker thread (`scripts/bounded-worker.ts`), stopped +at a wall-clock deadline or a heap cap defined in the benchmark file; a stop +fails the command, naming the task it reached. The command passes +`--no-flush-bytecode` for the same reason the scaling guardrail does (below). + +Results are machine-dependent; this repository does not record fixed numbers +here. Run the benchmark locally before and after a hot-path change and +compare the two runs directly, rather than against a number in this document. + +## Scaling guardrail + +`yarn test:scaling` (`test/performance/scaling-check.ts`, wired into `yarn +verify` and CI) times each hot operation (substitution, diagnostics, folding, +hover, completions, code fixes) at N and 4N, alternating the two sizes and +taking the minimum of several runs per size, and fails when time(4N)/time(N) +exceeds the threshold defined in that file. Each timing is the CPU time of the +thread running the check (`process.threadCpuUsage`), so time spent preempted by +other processes never counts; the script needs the development Node version for +that API. A +linear operation lands near 4; a quadratic one lands near 16; +the threshold sits between the two. Every timed result is also checked against +what its fixture must produce (a diagnostic per misspelled property, a fold +per rule, the expected substituted text), because the language service turns +a feature's exception into an empty result that is fast at every size; a +wrong result fails the check on its own, and so does a ratio below the floor +defined in that file, which means the timed call stopped doing work that +grows with its input. Heavy machine load can push one measurement's ratio past +the threshold or under the floor on unchanged code, so a ratio outside that +range prints a `retry 1/2` line and measures the check again, up to the retry +count defined in that file; the check fails only when every attempt lands +outside the range, which a real regression does. A wrong result is not +retried. This is the mechanical enforcement of the +AGENTS.md Performance rule against per-result or per-placeholder work +proportional to template length. It also runs a retained-heap guardrail that +drives a distinct edit session, several times longer than the diagnostics +validation cache's byte budget can hold, through +`StyledTemplateLanguageService.getSemanticDiagnostics` and fails if the heap +retained afterward exceeds a threshold derived from that byte budget +(`src/features/diagnostics.ts`, docs/architecture.md, Caching). It needs +`--expose-gc` and `--no-flush-bytecode`, which `yarn test:scaling` passes; a +run without either fails before any check starts. Without the second, V8 +flushes the bytecode of idle functions after a number of collections that +varies from run to run, which moves the retained-heap reading by megabytes. A +heap that drops below zero by more than the tolerance defined in +`test/performance/scaling-check.ts`, and a measured service that stops +answering, fail as a broken probe, reported separately from a reading over the +threshold. + +Each check, the retained-heap guardrail included, runs alone in its own worker +thread (`scripts/bounded-worker.ts`), which the script stops at a wall-clock +deadline or a heap cap, both defined in `test/performance/scaling-check.ts`. +Either stop fails that check as over the limit, naming the size and attempt it +reached, and most likely means a superlinear regression (a loop that never +ends included) or unbounded retention. Each check's line prints its wall time, +which a healthy run keeps far below the deadline even on a loaded machine. An +attempt whose ratio already sits far above the threshold after a few samples +stops sampling early; the check still fails only when every attempt does. A +budget defined in the same file bounds the whole run: each check's deadline is +cut to what remains of it, and checks left once it is spent fail unrun, so a +regression every check shares cannot run past CI's job timeout. No worker +outlives its check. + +Run one check in isolation, to red/green it without paying for the whole +suite, with `--filter `: every check (and the retained-heap +guardrail) whose name includes the substring runs, keeping the run budget and +per-check deadline above unchanged. A substring that matches nothing fails +loudly, listing every check's name, rather than silently running zero checks: + +```bash +corepack yarn test:scaling --filter 'JavaScript escape' +corepack yarn test:scaling --filter 'retained-heap guardrail' +``` + +## Comparing with a release + +`yarn compare-release` (`scripts/compare-release.ts`) runs the same templates +through the working build in `lib/` and a published release (1.0.1 unless +`--version` names another), each loaded as a tsserver plugin inside an +in-memory TypeScript language service, and prints what differs. Use it to +confirm that a change to user-visible behavior differs from the release only +where intended, and to find shapes where the release already behaved +differently from the working build. + +```bash +yarn compile +yarn compare-release # the default cases +yarn compare-release path/to/cases.mjs --all # every case, differing or not +yarn compare-release cases.mjs --filter '^(?!huge)' # only cases whose name matches +``` + +- Cases: a module whose default export is an array of + `{ name, code, ext?, config?, ops? }` (the `CompareCase` type in + `scripts/compare-release-cases.ts`, which holds the default cases). `⟨a⟩` in + `code` marks a position named `a`. `ops` lists `diag`, `fix`, `fold`, + `{ op: 'comp', at, show? }`, `{ op: 'hover', at }`, + `{ op: 'configure', configuration }`, and `{ op: 'change', from, to?, text }`, + and defaults to one `diag`. `config` holds plugin settings, as in + `tsconfig.json`. +- The release is fetched once with `npm pack --ignore-scripts` into + `node_modules/.cache/compare-release//`, and resolves its + dependencies from this repository's `node_modules`: nothing is installed and + no package script runs. Delete that directory to fetch it again. +- A release that throws is a result, printed as `threw` with the first line of + the error (tsserver fails the whole response for a file on one). +- Each build runs in its own worker thread, both builds of a case at once, and + each case is stopped at a wall-clock deadline or a heap cap defined in the + script. A stop is also a result: the operation it happened in reads as + stopped at the deadline or the heap cap, later ones as not run, and the run + goes on to the next case in a fresh worker for that build. +- The run fails with a message, exit code 1, when `lib/` is missing or older + than `src/`, when either build does not load, or when either build reports + nothing for a positive-control template with a misspelled property, which a + plugin that failed to load silently would also do. +- Blind spots: no real tsserver (protocol, closed files, and project loading + are the e2e suite's job); positions after a `change` op come from the markers + of the original code; the release runs on this repository's dependency + versions. + +## Project layout + +- `src/`: plugin implementation and public API source. In `src/virtual-document/`, `styled-virtual-document-provider.ts` builds the virtual document and its reading, `css-code-scanner.ts` holds the boundary scanner every structural read shares, and `template-line-map.ts` maps lines, positions, and ranges between the template and the virtual document. +- `test/unit/`: fast tests for mapping, configuration, and feature behavior. +- `test/e2e/scenarios/`: protocol-level scenarios that fork a real tsserver. +- `test/e2e/tsserver-fixture/`: the tsserver process harness. +- `test/e2e/*-project-fixture/`: fixture projects used by E2E scenarios. +- `test/performance/`: `scaling-check.ts` (see Scaling guardrail below) and its pure CLI helpers in `scaling-check-cli.ts` (`--filter` parsing, per-check progress tracking, both covered by `test/unit/scaling-check-cli.test.ts`); `template-language-service.bench.ts` and its shared fixtures in `template-language-service-fixture.ts` (see Performance baseline below); a standalone, flag-run memory probe with no build step, `validation-cache-key-flattening-probe.mjs` (the diagnostics validation cache's key, `buildValidationCacheKey` in `src/features/diagnostics.ts`), runnable directly with `node --expose-gc ` and self-checking against a held-forever control before trusting its own reading. +- `scripts/`: the changelog generator, the pack-contents check, the consumer type gate runner, changeset credits, the compare-release sensor, `bounded-worker.ts`, which runs plugin code for the scaling check, the benchmark, and compare-release in a worker thread with a deadline and a heap cap, `kill-group.ts`, the POSIX process-group signaling `with-deadline.ts` uses (split out so a test can exercise it without triggering that file's own CLI entry point), and `with-deadline.ts`, which gives `yarn test:unit` and `yarn test:e2e:run` a whole-run wall-clock deadline (see [Running tests](#running-tests)). +- `lib/`: generated build output; never edit it manually. + +E2E tests load the compiled `lib/` package; see [Running tests](#running-tests) +for when that build needs a rebuild. + +## Focused tests and tsserver debugging + +Run a single unit or E2E scenario through Yarn so the workspace-provided +Vitest binary and configuration loader are used, through the same deadline +wrapper `yarn test:unit` and `yarn test:e2e:run` use (raise the deadline with +`WITH_DEADLINE_MS` to keep a debugger attached to the one file past the +default): + +```bash +yarn node --experimental-strip-types scripts/with-deadline.ts 60000 -- vitest run test/unit/plugin-configuration.test.ts --configLoader runner +yarn compile && yarn node --experimental-strip-types scripts/with-deadline.ts 300000 -- vitest run test/e2e/scenarios/completions.test.ts --configLoader runner +``` + +E2E scenarios drive a real tsserver through `test/e2e/tsserver-fixture/`'s +`TSServer` wrapper (`server.ts`). Most scenario files share one tsserver +across every test in the file (`useSharedServer`, started before the first +test and closed after the last, `test/e2e/tsserver-fixture/helpers.ts`); a +scenario that needs an isolated instance, such as one exercising plugin +startup itself, uses `startServer` instead, which closes it when that one +test finishes. `TSServer.request` matches each response to its request by +`request_seq`, and stops that tsserver at once, failing the whole session, if +no response for a request arrives within `TSSERVER_RESPONSE_TIMEOUT_MS` +(`test/e2e/tsserver-fixture/timeouts.ts`), since a plugin stuck in a +synchronous loop never reads stdin again for `close()` to end it. To +investigate a plugin-host interaction, add or adjust the smallest scenario in +`test/e2e/scenarios/`, use `server.open()` to provide the source, marking +positions and ranges in it with `⟨name⟩` and `⟨/name⟩` and reading them back +through `mark()`'s `at`/`range` (`test/e2e/tsserver-fixture/markers.ts`), send +the relevant protocol command, then inspect the typed response helpers. Each +`TSServer` instance writes verbose tsserver output to its own gitignored file +under `test/e2e/tsserver-fixture/logs/`, cleared once per e2e run +(`test/e2e/tsserver-fixture/clear-logs.ts`, wired in as the `globalSetup` of +`vitest.config.ts`'s `e2e` project only, alongside `check-plugin-link.ts`, +which fails the run up front if the e2e workspace resolves the plugin package +to anywhere other than this repository's own build; a `test:unit` run, which +resolves to the separate `unit` project, leaves the directory untouched); +read it back in a scenario with `server.readLog()` to confirm plugin +discovery and request handling after a failing scenario, or after closing the +server, from disk directly. + +The only runtime call into `typescript-template-language-service-decorator` is +in `src/tsserver/tsserver-plugin.ts`. Keep that integration direct until a +confirmed compatibility defect requires a local adapter. Changes at this +boundary must run `yarn test:e2e:current`; the lifecycle and syntax scenarios +verify plugin loading, configured tag matching, and tag configuration updates +through a real tsserver host at the `typescript-current` alias version +(`test/e2e/package.json`). + +## Testing changes + +Add a unit test when changing template substitution, virtual document mapping, +configuration handling, or a feature implementation that can be exercised +without a process boundary. Add or update a tsserver scenario when the change +affects plugin discovery, tsserver protocol behavior, source-file handling, or +the interaction between the plugin and a real TypeScript host. + +CI builds the package with the Node.js version in `.github/.node-version`, then +verifies the resulting artifact on Node 22.12 and that version against the +TypeScript versions listed in the e2e matrix +of `.github/workflows/ci.yml` (the supported floor through the current +release); `yarn verify` only runs the e2e suite once, against the default +TypeScript version, so run `yarn test:e2e:minimum`, `yarn test:e2e:5.9`, and +`yarn test:e2e:current` locally too before relying on `verify` alone for a +change near the supported TypeScript range. The vitest-based E2E suite cannot +run on this package's own Node floor, 14.21.3 (docs/tsserver-host.md): vitest +declares a newer `engines.node` than that. A separate CI job +(`tsserver-smoke-floor`) drives a real tsserver directly with +`test/package-api/tsserver-smoke.cjs`, a plain-CommonJS script with no +build-tool dependency, against the packed +tarball on that floor Node; `yarn verify` does not run this job. Reproduce it +locally in two steps. Pack with the development Node, since Yarn 4 itself +requires Node 18.12 or newer: + +```bash +yarn pack:artifact +``` + +Then run the smoke script with a Node 14.21.3 binary specifically on `PATH` +(the whole point is the package's own floor, so running it with the +development Node instead proves nothing about it; a version manager such as +`nvm install 14.21.3 && nvm use 14.21.3` or +`fnm install 14.21.3 && fnm use 14.21.3` selects one): + +```bash +node test/package-api/tsserver-smoke.cjs package-artifact/package.tgz node_modules/typescript-minimum +``` + +Neither job establishes compatibility with a specific editor. Before claiming +support for an additional host, verify its TypeScript and Node versions meet +the package requirements and that it can load the plugin through the normal +`plugins` configuration path. + +## Packaging and pull requests + +`package.json` publishes only `lib/`; runtime dependencies are installed by npm +from the package's `dependencies`. The `prepack` lifecycle rebuilds `lib/` +before every tarball is created, including on a dry run, and tsdown empties +`lib/` before each build, so a tarball never carries a stale file: Yarn's +`enableScripts: false` disables dependencies' install scripts, not this +package's own `prepack`. Every tarball this repository builds, in CI and in +tests, comes from `yarn pack`, the packer `yarn npm publish` uses, through +`yarn pack:artifact`. The package API test (`test/package-api/runtime.ts`) +extracts that tarball into a throwaway project and loads the synchronous +tsserver entry and the ESM API from it, each through both `require()` and +`import()`. Inspect the release contents with: + +```bash +yarn pack --dry-run +``` + +`yarn test:pack` (`scripts/check-pack-contents.ts`, wired into `yarn verify` +and CI) lists the same tarball and fails listing every missing and +unexpected file. It expects every path `package.json` points a consumer at +(`main`, `types`, `exports`, `typesVersions`), plus the build-hook files and +the root files Yarn always packs, both listed in the script. A `files` glob or +negation, or an `exports` or `typesVersions` wildcard pattern, fails the check +with a message naming it, since the script compares literal paths only. + +Keep changes focused and include tests at a scope proportionate to the behavior +change. Use a feature branch, push it to your fork, and open a pull request +against this repository. All contributors must follow the +[Code of Conduct](../CODE_OF_CONDUCT.md). + +## Releases + +`package.json`'s `workspaces` field lists `.` alongside `test/e2e`. Without +that entry, `@changesets/cli`'s workspace detection (`@manypkg/get-packages`) +treats the presence of any `workspaces` field as turning this into a +monorepo, and stops treating the root package as a package at all, so every +changesets command fails with "which is not in the workspace". Listing `.` as +a workspace member fixes that without changing how `test/e2e` itself +installs or links against the root package. + +Every noteworthy change or fix ships with a changeset: + +```bash +corepack yarn changeset +``` + +This prompts for a bump type (patch or minor; use major only for an +intentional break in the public surface, AGENTS.md "Correctness and types") +and a description, then writes a file under `.changeset/`. Write the +description in declarative release-note voice (what changed, for whom), never +first-person, matching the tone of the existing `.changeset/*.md` files. +Before crediting anyone by name, run: + +```bash +corepack yarn changeset-credits +``` + +This lists the git authors and co-authors behind every pending changeset, so a +thank-you is derived from history rather than recalled from memory. + +Release automation lives in `.github/workflows/release.yml`. On every push to +`main`, it runs `yarn verify`, then either opens or updates a "Version +Packages" pull request (one `changeset version` behind pending changesets) or, +once that pull request is merged, builds and publishes the package. A failing +`verify` stops the job before anything is published. Nobody bumps `version` or runs +`npm publish` by hand. + +Publishing itself needs no long-lived npm token: it relies on +[npm trusted publishing](https://docs.npmjs.com/trusted-publishers), which +exchanges the workflow's own GitHub Actions OIDC identity for a short-lived +npm credential. This is a one-time, maintainer-only setup step, not something +a contribution needs to touch: + +1. On npmjs.com, open `@styled/typescript-styled-plugin`'s settings and add a + GitHub Actions trusted publisher. +2. Point it at the `styled-components/typescript-styled-plugin` repository and + the `release.yml` workflow filename (`.github/workflows/release.yml`). +3. Under the allowed actions, also allow direct publishing with `npm publish`. + A trusted publisher created after September 3, 2026 allows only + `npm stage publish` by default, and the release workflow publishes + directly rather than staging a release for later approval. + +The release workflow publishes with `yarn npm publish`, which performs that +exchange against the registry in `package.json`'s `publishConfig.registry` +(`https://registry.npmjs.org`) rather than Yarn's default, +`registry.yarnpkg.com`, which forwards to npm. Until the trusted publisher is +configured with direct publishing allowed, the publish step fails with an +authentication error; it is not something a change to this repository's code +can fix. + +CI releases carry npm provenance: the release workflow sets Yarn's +`npmPublishProvenance` setting (as `YARN_NPM_PUBLISH_PROVENANCE`), so each +published version links to the workflow run that built it. Yarn signs the +provenance statement before it contacts the registry, with no fallback, so a +release requires: + +- the `id-token: write` permission in `release.yml` (without it, Yarn stops + with "Provenance generation in GitHub Actions requires "write" access to the + "id-token" permission"); +- a runner that reaches Sigstore's certificate authority, Fulcio + (`https://fulcio.sigstore.dev`), and its transparency log, Rekor + (`https://rekor.sigstore.dev`), the default endpoints Yarn calls. + +When Sigstore is unavailable, the publish step fails before anything reaches +npm. Re-run the release job once Sigstore recovers (its status page is +[status.sigstore.dev](https://status.sigstore.dev)). The re-run is safe: +`changeset publish` publishes only versions the registry does not list yet, +and it creates a git tag only for a version it published, so the failed +attempt leaves no tag, GitHub release, or registry entry behind to block the +retry. diff --git a/docs/tsserver-host.md b/docs/tsserver-host.md new file mode 100644 index 0000000..213d35f --- /dev/null +++ b/docs/tsserver-host.md @@ -0,0 +1,54 @@ +# tsserver host knowledge + +Verified facts about the environment this plugin runs in. Dated 2026-09-26; each item names the version it was checked against. Re-verify before relying on an item older than a few months. + +## How tsserver loads a plugin + +- The Node tsserver host loads plugins with a synchronous `require()` (TypeScript 5.9 and 6.0: `src/compiler/sys.ts` `nodeSystem.require`, `src/server/project.ts` `importServicePluginSync`). The async `importPlugin` path exists only for hosts that implement it; the Node CLI host does not. +- Resolution uses classic Node10 rules from the host's own directory or `--pluginProbeLocations`: `package.json` `main` is what tsserver finds, not the `exports` map (`NodeResolutionFeatures.None`, no `Exports` feature bit, in `tryResolveJSModuleWorker`). The plugin probe is relative to the realpath of the `typescript` package tsserver loaded, not the project directory, so a symlinked `typescript` install (a version-manager shim, a monorepo hoist) sends the probe to the symlink target's tree; a test harness assembling an isolated project must copy `typescript` in, not symlink it. +- TypeScript 4.4 and older do not resolve a `.mjs` `main` at all ("Could not resolve JS module" in the tsserver log). The same is true of a `.cjs` main: measured against a real tsserver on TypeScript 3.9, 4.0, and 4.4 with `main: "./lib/index.cjs"`, the log shows the same "Could not resolve JS module" failure, trying `lib/index.cjs.js`, `lib/index.cjs/index.js`, and similar mangled suffixes rather than the literal file; TypeScript 4.9 and newer resolve `.cjs` correctly. This package's root `main` is `lib/index.js` (a real CommonJS file, disambiguated from the package root's `"type": "module"` by a nested `lib/package.json`), which every supported TypeScript version's Node10 resolution finds. +- `typesVersions` (`package.json`) applies to this same classic-resolution code path, not only to a consumer's own type lookup: `tryResolveJSModuleWorker` shares the "resolve `main` or a subpath" logic with the types resolver, so a `typesVersions` entry keyed on the same relative path as `main` (here, `lib/index` or `lib/index.js`) redirects the plugin loader's own runtime `require()` to the declaration file instead of the implementation, which then throws `SyntaxError: Cannot use import statement outside a module` trying to execute it. Measured on TypeScript 5.0 by adding and removing such an entry against a real tsserver. A `typesVersions` entry for any OTHER subpath (one `main` itself never resolves to, such as `./api`, `./lib/api`, or `./lib/api.js` here) does not collide, since the plugin loader never requests those paths. +- The configuration a plugin receives at creation (`PluginCreateInfo.config`) is the whole tsconfig `plugins` entry, `name` included. A plugin an editor loads globally (`--globalPlugins`, as VS Code does for an extension's `typescriptServerPlugins`) receives `{ name, global: true }`, and a `configurePlugin` payload that arrives before the project loads replaces the entry, with `name` set on it. A later `configurePlugin` payload reaches `onConfigurationChanged` as sent. Checked in TypeScript 5.0.4 (`lib/tsserver.js`) and 6.0.3 (`lib/typescript.js`): `enableGlobalPlugins`, `endEnablePlugin`, `enableProxy`, `onPluginConfigurationChanged`. +- A plugin that fails to load or activate never surfaces to the user: the only trace is the tsserver log. An empty completion response from tsserver also reports `success: false`, so harnesses must read the log to distinguish "plugin skipped" from "no results". + +## TypeScript versions (2026-09-26) + +- `typescript@latest` on npm is 7.0.2, the native compiler. The package ships no `tsserver` and has no plugin API; VS Code's TypeScript 7 mode does not load `typescriptServerPlugins`. Plugins run only on TypeScript 5.x and 6.x hosts; an older host can still load the plugin module itself without erroring, since this plugin's own version gate (below) returns the host's language service untouched rather than activating. +- VS Code stable bundles TypeScript 6 (`npm:@typescript/typescript6`). +- Floor for this plugin: TypeScript 5.0 (`major >= 5`, `isSupportedTypeScriptVersion`, `src/tsserver/tsserver-plugin.ts`). 1.0.1 gates on `major >= 3`, but `typescript-template-language-service-decorator` 2.3.2 (the last release) binds `languageService.getSupportedCodeFixes` whenever the template service passed to it implements the same method, regardless of host version; that instance method exists on `ts.LanguageService` only from TypeScript 5.0 (confirmed via `ts.createLanguageService(...).getSupportedCodeFixes` returning `undefined` on 3.9, 4.0, 4.4, and 4.9, and a function from 5.0), so binding it on an older host throws `TypeError: Cannot read properties of undefined (reading 'bind')` before any feature activates. Reproduced against a real tsserver on TypeScript 3.9, 4.0, 4.4, and 4.9 with the published decorator's own logged message: "Plugin activation failed: TypeError: Cannot read properties of undefined (reading 'bind')". 1.0.1 crashes identically on these versions today, since a fresh install resolves the same final decorator release. This plugin's own gate never constructs the decorator below TypeScript 5.0, logging "Unsupported TypeScript version ... TypeScript 5.0 or newer required" and returning the host's own language service untouched instead of crashing there. +- Line terminators: `ts.computeLineStarts` starts a new line after `\r\n`, a lone `\n`, a lone `\r`, U+2028, and U+2029, and not after U+0085. Checked on TypeScript 4.9.5, 5.0.4, 5.9.3, and 6.0.3 (`computeLineStarts("a\r\nb\nc\rd
e
f\u0085g")` returns `[0,3,5,7,9,11]` on each). +- A declaration file using a string export name (`export { x as 'module.exports' }`) fails to parse (TS1003) on TypeScript 5.0 through 5.5; 5.6 accepts it. This package no longer emits that syntax anywhere (the root entry's declaration uses `export =`, parseable since TypeScript's earliest CommonJS-module support); kept here as the reason a future root-entry declaration should avoid the string-export-name form. + +## Node runtime for the tsserver plugin and the `./api` subpath + +- The package root (the tsserver plugin factory) is plain CommonJS: `require()` works on every Node version this plugin's dependencies support, with no `require(esm)` interop involved. tsdown's `target` for every output (root CJS, `./api` CJS, `./api` ESM) is `node14`, so syntax this package's own source uses (`||=`, optional chaining, and similar) is transpiled to a Node 14-safe form; tsdown does not polyfill runtime APIs, and this package's source uses none introduced after Node 14 (checked: no `Array.prototype.at`, `Object.hasOwn`, `structuredClone`, or `String.prototype.replaceAll`). Measured with the packed tarball's root, both `require()` and `import()`: Node 14.21.3, 16.20.2, 18.20.5, 20.18.1, 22.12.0, and 24.11.0 all load it and return the factory (`import()` gets it as the default export, since Node's ESM loader synthesizes a default from a CommonJS module's `module.exports`). +- Node 12.22.12 fails for both this package and 1.0.1, from the same cause neither controls: this package's runtime `dependencies` (`vscode-css-languageservice` 6.3.10, `@vscode/emmet-helper` 2.11.0) ship optional chaining (`?.`) in their own published CommonJS builds (`vscode-css-languageservice/lib/umd/cssLanguageService.js`, `@vscode/emmet-helper/lib/cjs/emmetHelper.js`), which Node 12 does not parse (`SyntaxError: Unexpected token '.'`) regardless of this package's own build target. 1.0.1's `^6.2.11`/`^2.9.2` dependency ranges resolve to these same exact versions today (a fresh `npm install` of 1.0.1 gets `vscode-css-languageservice@6.3.10` and `@vscode/emmet-helper@2.11.0`, identical to this package's pinned versions), so 1.0.1 fails identically: reproduced against a real tsserver on TypeScript 3.9, 4.4, and 4.9, both packages showing no plugin activation (log: "Failed to load module '@styled/typescript-styled-plugin'... `vscode-css-languageservice/lib/umd/cssLanguageService.js`... `SyntaxError: Unexpected token '.'`" for 1.0.1; this package's own bundle fails one line into its own code for the same underlying reason, since its build target permits, rather than removes, optional chaining). Node 14.21.3 is therefore the actual common floor, not Node 16.20.2: 1.0.1's own actual floor, even though 1.0.1 never declared one. +- `./api` is a real dual build: `import` gets `lib/esm/api.mjs`, `require` gets `lib/esm/api.cjs`, a genuine CommonJS bundle. Measured with the packed tarball's `./api`, both `require()` and `import()` succeed on Node 14.21.3, 16.20.2, 18.20.5, 20.18.1, 22.12.0, and 24.11.0 (the same Node floor as the package root, with no `require(esm)` dependency). +- `require()` of an ES module throws `ERR_REQUIRE_ASYNC_MODULE` if the module graph uses top-level `await`. Keep the `lib/esm/api.mjs` bundle free of it. +- `export { value as 'module.exports' }` sets what `require()` of an ES module returns; named exports are then invisible to CommonJS callers. Not used by this package (see the TypeScript-versions note above); documented here because `require(esm)` interop depends on it. +- Host runtimes: VS Code and Cursor run tsserver on their Electron Node (24.x in 2026). `typescript-language-server` requires Node 22.22 or newer. `@vtsls/language-server` declares Node 18 or newer, so a vtsls user on an older system Node cannot load the plugin. `typescript.tsserver.nodePath` in VS Code substitutes the user's Node. + +## Dependencies + +- `typescript-template-language-service-decorator` 2.3.2 (2023) is the last release; the upstream repository is archived, so any fix needs a fork or vendoring. It is CommonJS. +- `vscode-css-languageservice`, `@vscode/emmet-helper`: CommonJS `main` with an ESM `module` field and no `exports` map; Node always loads the CommonJS build. +- `vscode-css-languageservice` 6.3.10's runtime enums (`CompletionItemKind`, `DiagnosticSeverity`, and similar) are not visible to a Node ESM named import: with no `exports` map, Node's ESM loader statically analyzes the CommonJS build to synthesize named exports, and that analysis does not find them. Measured on Node 24.11.0: `node --input-type=module -e "import { CompletionItemKind } from 'vscode-css-languageservice'"` throws `SyntaxError: Named export 'CompletionItemKind' not found`, while the same import from `vscode-languageserver-types` 3.18.4 succeeds. This plugin imports those enums from `vscode-languageserver-types` instead (`src/features/completions.ts`, `src/features/diagnostics.ts`, `src/features/code-actions.ts`), the package `vscode-css-languageservice` itself depends on for the same numeric values. + +## Tag matching + +The decorator matches a tagged template by the tag's source text. A configured name `n` matches when the tag text is `n`, starts with `n.`, `n(`, `n<`, or `n[`, or ends with `.n`. Only tagged templates are candidates: a plain call like `globalStyle('body', {...})` never matches. Short generic names therefore also match unrelated tagged templates that share the prefix. + +## Downstream + +- `styled-components/vscode-styled-components` bundles this package and registers it through `contributes.typescriptServerPlugins` with `enableForWorkspaceTypeScriptVersions: true`. The extension depends on `^1.0.0`; a new major reaches extension users only after the extension updates that range. + +## Driving tsserver in tests + +- Open the probe file without `projectFileName` and keep its `tsconfig.json` in the same directory: passing `projectFileName` makes tsserver build an inferred project that ignores the config. +- Derive caret positions from markers in the source, never hand-counted columns, and pair every assertion that expects nothing (no diagnostics, no completions) with a positive control in the same session, since a plugin that failed to load also returns nothing. +- Escape U+2028 and U+2029 in request JSON; the line-delimited protocol treats them as line breaks. +- Match responses by `request_seq`, not by command name. +- A notification (`open`, `change`) gets a response on TypeScript 5.9.3 and 6.0.3 and none on 5.5.4, 5.0.4, and 4.9.5 (`Session.notRequired`), so a harness awaits only requests and discards a notification's response when one arrives. +- Sending `open` with `fileContent` for a file that is already open replaces its text and marks its projects dirty (`ScriptInfo.open`, 4.9.5 through 6.0.3), so one tsserver can serve many cases that each reopen the file with their own content. +- tsserver tries `--pluginProbeLocations` before its own install's `node_modules` (the log line "Enabling plugin ... from candidate paths"), so the e2e suite loads the plugin through the `test/e2e` workspace link; the e2e globalSetup (`test/e2e/tsserver-fixture/check-plugin-link.ts`) fails the run when that link resolves outside this repository. +- To find tests that pass without the plugin, point that link at a stub package whose factory's `create` returns `info.languageService` unchanged (and turn the link check off for the run): only tests that never depend on the plugin (TypeScript's own completions inside a placeholder, a missing-plugin project, the marker helper's own tests) may still pass. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..ea58117 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,304 @@ +# Usage + +`@styled/typescript-styled-plugin` provides CSS IntelliSense, diagnostics, code +fixes, folding, hover information, and Emmet completions in configured tagged +template literals. + +## Host requirements + +The plugin activates on tsserver hosts that use TypeScript 5.0 or newer (6.x +recommended) and Node.js 14.21.3 or newer. On an older TypeScript host the +plugin logs a message and leaves the host's `LanguageService` untouched +instead of activating. The tsserver plugin entry is CommonJS, loaded with a +synchronous `require()`, with no ESM interop involved. The automated suite +covers the standard Node.js tsserver path; installation alone does not prove +that an editor can load the plugin. See +[docs/tsserver-host.md](tsserver-host.md) for the verified version and +loading details behind these requirements. + +A library consumer embedding the language service directly through +`@styled/typescript-styled-plugin/api` needs the same Node floor, for both +`import()` and `require()`. See [docs/tsserver-host.md](tsserver-host.md). + +## Install and configure + +Install the plugin beside the TypeScript version that the editor will use: + +```bash +npm install --save-dev @styled/typescript-styled-plugin typescript@^6.0.3 +``` + +Add the plugin to the project `tsconfig.json` or `jsconfig.json`: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin" + } + ] + } +} +``` + +The editor must be configured to use that workspace TypeScript SDK, or, in +Neovim, load the plugin as a global plugin. VS Code, Sublime Text, Neovim, and +Visual Studio setup paths are described in the +[README](../README.md#editor-integration); each requires validation against +the actual editor's tsserver host and Node runtime. + +## Tagged templates + +By default, the plugin recognizes these tag names: + +```text +styled, css, keyframes, createGlobalStyle, globalCss, injectGlobal, extend +``` + +Tag matching is based on the configured name at the start or end of the tag +expression. For example, `styled.keyframes` is recognized as `keyframes` and +uses keyframe parsing. The plugin does not resolve imports, so aliases such as +`kf` must be included in `tags` and do not automatically receive keyframe +parsing. + +For example: + +```ts +import styled from 'styled-components' + +const Button = styled.button` + color: blue; +` +``` + +Tags also match a tagged template whose tag is a property-access expression +ending in the configured name, such as `Button.extend`, which ends with +`extend`. This is enabled by default: + +```ts +const FancyButton = Button.extend` + border: 10px solid hotpink; +` +``` + +styled-components removed `.extend` in v4 (use `styled(Component)` instead); +`extend` stays in the default tag list for projects still on an older +styled-components version, or any other tagged-template API shaped the same +way. + +Set `tags` to replace the complete default list rather than add to it: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "tags": ["styled", "css", "sty"] + } + ] + } +} +``` + +## styled-components v7 + +The plugin works with styled-components v7 without extra setup: + +- The default tags cover v7's `styled`, `css`, `keyframes`, and + `createGlobalStyle`. Templates from `styled-components/native` (for example + `` styled.View`...` ``) are recognized under the same names. +- Nested at-rules inside a component, such as `@media`, `@supports`, + `@container`, `@layer`, `@scope`, and `@starting-style`, validate without + false errors, and typing `@` inside a component suggests them. +- Interpolations that stand in for whole declarations, such as + `color: red; ${mixin}` or `&:hover { ${mixin} }`, and `css` + fragments used as a value, such as `` css`${fadeIn} 1s linear` ``, validate + without false errors. + +React Native-only property names such as `margin-vertical` pass through v7 +unchanged, but they are not CSS, so the plugin reports them as unknown +properties. List them in `lint.validProperties`, or silence unknown properties +entirely with `unknownProperties: "ignore"`: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "lint": { + "validProperties": ["margin-vertical", "margin-horizontal"] + } + } + ] + } +} +``` + +The plugin matches tags by name and does not follow imports, so an aliased +import needs its name in `tags`. For example, after +`import native from 'styled-components/native'`, templates written as +`` native.View`...` `` need `native` added (remember that `tags` replaces the +default list, so keep the defaults you still use): + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "tags": ["styled", "css", "keyframes", "createGlobalStyle", "native"] + } + ] + } +} +``` + +## Validation and linting + +CSS diagnostics are enabled by default. Disable them with `validate: false`: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "validate": false + } + ] + } +} +``` + +Use `lint` to pass CSS validation settings to +`vscode-css-languageservice`. Each listed setting accepts `"ignore"`, +`"warning"`, or `"error"` unless noted otherwise. + +Library consumers can use the exported `StyledPluginLintConfiguration` and +`StyledPluginEmmetConfiguration` types for static checking. The plugin still +accepts unknown object settings from tsserver at runtime so newer upstream +settings do not cause a host failure. + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "lint": { + "vendorPrefix": "error", + "zeroUnits": "ignore" + } + } + ] + } +} +``` + +| Setting | Purpose | Default | +| --------------------------------- | ------------------------------------------------------------------- | --------- | +| `validProperties` | Extra property names treated as valid. This is an array of strings. | Not set | +| `unknownAtRules` | Unknown CSS at-rules. | `warning` | +| `unknownProperties` | Unknown CSS property names. | `warning` | +| `compatibleVendorPrefixes` | Missing related vendor-prefixed properties. | `ignore` | +| `vendorPrefix` | Vendor-prefixed properties without a standard equivalent. | `warning` | +| `duplicateProperties` | Duplicate style declarations. | `ignore` | +| `emptyRules` | Empty rulesets. | `ignore` | +| `importStatement` | `@import` statements. | `ignore` | +| `boxModel` | Width or height used with padding or borders. | `ignore` | +| `universalSelector` | Universal selectors. | `ignore` | +| `zeroUnits` | Units on zero values. | `ignore` | +| `fontFaceProperties` | Missing `src` or `font-family` in `@font-face`. | `warning` | +| `hexColorLength` | Invalid hexadecimal color length. | `error` | +| `argumentsInColorFunction` | Invalid color-function argument count. | `error` | +| `ieHack` | Legacy IE hacks. | `ignore` | +| `unknownVendorSpecificProperties` | Unknown vendor-specific properties. | `ignore` | +| `propertyIgnoredDueToDisplay` | Properties ineffective for the selected `display`. | `warning` | +| `important` | `!important` declarations. | `ignore` | +| `float` | `float` declarations. | `ignore` | +| `idSelector` | ID selectors. | `ignore` | + +## Shorthand values and mistakes + +A few spellings from other tools are accepted as the value they clearly mean: + +| Setting | Also accepts | +| ---------------------- | ------------------------------------------------------------------------------------------- | +| `tags` | One string, as a one-item list: `"sty"` | +| `validate` | The strings `"true"` and `"false"` | +| `lint` levels | `"off"` for `"ignore"`, `"warn"` for `"warning"`, and any letter case (`"Error"`, `"WARN"`) | +| `lint.validProperties` | One string, as a one-item list: `"margin-vertical"` | + +Any other value of the wrong kind, such as `"unknownProperties": 0` or +`"tags": 5`, is ignored and that setting keeps its default. The plugin writes +one line per ignored value to the TypeScript server log, naming the setting, +what it accepts, what it received, and which default applies: + +```text +[ts-styled-plugin] Ignored the plugin setting lint.unknownProperties: it accepts "ignore", "warning", or "error"; received 0. The CSS language service's own default for this rule applies. +``` + +A misspelled setting name, such as `"tag"` for `"tags"`, is ignored the same +way, and its line suggests the setting it most resembles: + +```text +[ts-styled-plugin] Ignored the plugin setting tag: the plugin has no setting with that name. Did you mean tags? The plugin settings are emmet, lint, tags, and validate. +``` + +In VS Code, turn the log on with the `js/ts.tsserver.log` setting (named +`typescript.tsserver.log` in older versions; any value other than `"off"`), +then open it with the **TypeScript: Open TS Server log** command. + +## Settings sent by the editor + +An editor or extension can send settings at runtime through tsserver's +`configurePlugin` request. Each such request replaces the plugin settings from +`tsconfig.json` as a whole rather than merging into them: a setting the +request leaves out returns to its default, not to the `tsconfig.json` value. + +## Emmet completions + +Emmet abbreviations are included in CSS completion lists where a declaration +can start, such as `m10` for `margin: 10px;`. Inside a property value, a +selector with a pseudo-class (`&:hover m10 {`), or an at-rule prelude +(`@media (min-width: m10)`), only Emmet expansions that are themselves values +are offered, such as `#12` for `#121212` or `!` for `!important`, so accepting +one never puts a declaration where none fits. Inside comments and strings, +including a string not yet closed, the plugin offers no completions at all. + +Configure Emmet through the `emmet` object, which is forwarded to +`@vscode/emmet-helper`: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "name": "@styled/typescript-styled-plugin", + "emmet": { + "showExpandedAbbreviation": "always", + "showSuggestionsAsSnippets": true, + "preferences": {} + } + } + ] + } +} +``` + +| Setting | Purpose | Allowed values | Default | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------- | ------- | +| `showExpandedAbbreviation` | Show the expanded Emmet abbreviation in the completion list; `"never"` turns Emmet completions off. | `"always"`, `"never"` | Not set | +| `showSuggestionsAsSnippets` | Show Emmet suggestions as snippets, ordered by the editor's snippet-suggestion setting. | `true`, `false` | Not set | +| `excludeLanguages` | Languages with Emmet turned off. The plugin asks for `css` completions, so `["css"]` turns Emmet completions off. | list of strings | Not set | +| `preferences` | Free-form object forwarded to `@vscode/emmet-helper` to adjust Emmet's actions and resolvers. | object | `{}` | +| `syntaxProfiles` | Output profiles per syntax, as in VS Code's `emmet.syntaxProfiles`; the `css` profile applies here. | object | Not set | +| `variables` | Values for the variables Emmet snippets use, as in VS Code's `emmet.variables`. | object | Not set | +| `showAbbreviationSuggestions` | Accepted for parity with VS Code's setting, but it only adds HTML abbreviation suggestions, so it has no effect in CSS. | `true`, `false` | Not set | + +TypeScript does not refresh an existing Emmet completion entry while its +abbreviation changes. Request completion again from the editor after updating +the abbreviation. From 6b6cf0230e43296f4549c87445a9a1f5993e9d80 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Sun, 27 Sep 2026 15:12:45 -0400 Subject: [PATCH 5/5] fix(test): reject timeout overrides above Node's timer limit A delay above 2147483647 ms makes Node fire the timer after 1 ms, so an oversized TSSERVER_RESPONSE_TIMEOUT_MS or WITH_DEADLINE_MS failed the run at once instead of extending it. --- scripts/with-deadline.ts | 10 +++++++--- test/e2e/tsserver-fixture/timeouts.ts | 12 ++++++++---- test/unit/tsserver-timeouts.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 test/unit/tsserver-timeouts.test.ts diff --git a/scripts/with-deadline.ts b/scripts/with-deadline.ts index 20a7eb3..af0ee6b 100644 --- a/scripts/with-deadline.ts +++ b/scripts/with-deadline.ts @@ -30,6 +30,9 @@ import { killGroup } from './kill-group.ts' const DEADLINE_OVERRIDE_ENV_VAR = 'WITH_DEADLINE_MS' +/** Node fires any timer delay above this after 1 ms instead, which would kill the run at once. */ +const MAX_TIMER_DELAY_MS = 2 ** 31 - 1 + function usageError(message: string): never { console.error(message) console.error( @@ -42,11 +45,12 @@ function parseDeadlineMs(defaultArg: string): number { const override = process.env[DEADLINE_OVERRIDE_ENV_VAR] const source = override ?? defaultArg const value = Number(source) - if (!Number.isFinite(value) || value <= 0) { + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + const expected = `expected a number of milliseconds from 1 to ${MAX_TIMER_DELAY_MS}.` usageError( override === undefined - ? `Invalid deadline "${defaultArg}": expected a positive number of milliseconds.` - : `Invalid ${DEADLINE_OVERRIDE_ENV_VAR}="${override}": expected a positive number of milliseconds.`, + ? `Invalid deadline "${defaultArg}": ${expected}` + : `Invalid ${DEADLINE_OVERRIDE_ENV_VAR}="${override}": ${expected}`, ) } return value diff --git a/test/e2e/tsserver-fixture/timeouts.ts b/test/e2e/tsserver-fixture/timeouts.ts index fae6bf3..9ee1658 100644 --- a/test/e2e/tsserver-fixture/timeouts.ts +++ b/test/e2e/tsserver-fixture/timeouts.ts @@ -9,16 +9,20 @@ const DEFAULT_RESPONSE_TIMEOUT_MS = 30_000 const responseTimeoutVariable = 'TSSERVER_RESPONSE_TIMEOUT_MS' -function parsePositiveInteger(name: string, value: string | undefined): number | undefined { +/** Node fires any timer delay above this after 1 ms instead, so a larger timeout would fail at once. */ +const MAX_TIMER_DELAY_MS = 2 ** 31 - 1 + +export function parsePositiveInteger(name: string, value: string | undefined): number | undefined { if (value === undefined) { return undefined } - if (!/^[1-9]\d*$/.test(value)) { + const parsed = Number(value) + if (!/^[1-9]\d*$/.test(value) || parsed > MAX_TIMER_DELAY_MS) { throw new Error( - `${name} is "${value}", which is not a whole number of milliseconds above zero. Set it to a value such as 60000, or unset it to use ${DEFAULT_RESPONSE_TIMEOUT_MS}.`, + `${name} is "${value}", which is not a whole number of milliseconds from 1 to ${MAX_TIMER_DELAY_MS}. Set it to a value such as 60000, or unset it to use ${DEFAULT_RESPONSE_TIMEOUT_MS}.`, ) } - return Number(value) + return parsed } export const responseTimeoutMs = diff --git a/test/unit/tsserver-timeouts.test.ts b/test/unit/tsserver-timeouts.test.ts new file mode 100644 index 0000000..4417bda --- /dev/null +++ b/test/unit/tsserver-timeouts.test.ts @@ -0,0 +1,27 @@ +import { assert, describe, it } from 'vitest' + +import { parsePositiveInteger } from '../e2e/tsserver-fixture/timeouts' + +describe('parsePositiveInteger', () => { + it('returns undefined for an unset variable, so the default applies', () => { + assert.strictEqual(parsePositiveInteger('TIMEOUT_MS', undefined), undefined) + }) + + it.each([ + ['1', 1], + ['60000', 60_000], + ['2147483647', 2 ** 31 - 1], + ])('accepts %s', (value, expected) => { + assert.strictEqual(parsePositiveInteger('TIMEOUT_MS', value), expected) + }) + + it.each(['0', '-5', '1.5', '60s', '', '2147483648', '99999999999999999999999'])( + 'rejects %j, naming the variable and the accepted range', + (value) => { + assert.throws( + () => parsePositiveInteger('TIMEOUT_MS', value), + `TIMEOUT_MS is "${value}", which is not a whole number of milliseconds from 1 to 2147483647.`, + ) + }, + ) +})