diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..b9707c6c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +vscode-extension/test/e2e/ +vscode-extension/test/web/ diff --git a/.eslintrc.json b/.eslintrc.json index 58434dfe..af913008 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,6 +14,7 @@ ], "plugins": ["@typescript-eslint", "prettier", "import"], "rules": { + "@typescript-eslint/ban-types": "off", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-explicit-any": "off", "import/no-unresolved": "off", diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..69195962 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [wkillerud] +ko_fi: williamkillerud diff --git a/.gitignore b/.gitignore index f738a64d..bd46a3a1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,14 @@ logs/ *.log npm-debug.log* +.cpuprofile +.heapprofile # Dependency directory node_modules/ !vscode-extension/test/fixtures/node_modules !vscode-extension/test/fixtures/completion/node_modules +!vscode-extension/test/fixtures/pkg-import/node_modules # Compiled and temporary files dist/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5f7da0c0..0ee6679b 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["amodio.tsl-problem-matcher"] + "recommendations": ["vitest.explorer"] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 66b1d675..079562a4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "autoAttachChildProcesses": true }, { - "name": "Launch Web Extension in VS Code", + "name": "Launch web extension", "type": "extensionHost", "debugWebWorkerHost": true, "request": "launch", @@ -24,51 +24,14 @@ "autoAttachChildProcesses": true }, { - "name": "Integration Tests", + "name": "Launch end-to-end tests", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceFolder}/vscode-extension", - "--extensionTestsPath=${workspaceFolder}/vscode-extension/out/test/e2e/suite", - "${workspaceFolder}/vscode-extension/out/test/fixtures/" - ], - "outFiles": ["${workspaceFolder}/vscode-extension/out/test/e2e/**/*.js"] - }, - { - "type": "chrome", - "request": "attach", - "name": "Attach to web integration test", - "skipFiles": ["/**"], - "port": 9229, - "timeout": 30000, // give it time to download vscode if needed - "resolveSourceMapLocations": [ - "!**/vs/**", // exclude core vscode sources - "!**/static/build/extensions/**" // exclude built-in extensions - ], - "presentation": { - "hidden": true - } - }, - { - "type": "node", - "request": "launch", - "name": "Launch web integration test", - "outputCapture": "std", - "program": "${workspaceFolder}/vscode-extension/out/test/web/runTest.js", - "args": ["--waitForDebugger=9229"], - "cascadeTerminateToConfigurations": ["Launch web integration test"], - "presentation": { - "hidden": true - } - } - ], - "compounds": [ - { - "name": "Web Integration Tests", - "configurations": [ - "Launch web integration test", - "Attach to web integration test" + "--extensionTestsPath=${workspaceFolder}/vscode-extension/test/e2e/suite", + "${workspaceFolder}/vscode-extension/test/fixtures/" ] } ] diff --git a/.vscode/settings.json b/.vscode/settings.json index d04a8adb..01b9ec61 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "mochaExplorer.cwd": "server", "somesass.scannerDepth": 30, "somesass.scannerExclude": [ "**/.git/**", diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 27f9f185..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "install", - "group": "none" - } - ] -} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..cdeb6303 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +While not an official Sass project, we strive to follow the [Sass Community Guidelines](https://sass-lang.com/community-guidelines/). + +- Be considerate +- Be open and inviting +- Be respectful +- Take responsibility for our words and actions +- Be collaborative +- Value decisiveness, clarity and open communication +- Be responsive and helpful +- Step down considerately diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0c3468c..f2a0b230 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,152 +1,8 @@ # Contributing -Thank you for showing an interest in contributing, be it to the language server or to the VS Code extension 🌟 +Thank you for showing an interest in contributing, be it to the language server, to the VS Code extension, the documentation, or in some other way 🌟 -Before you start, please make a [new Issue](https://github.com/wkillerud/some-sass/issues/new/choose). I don't always make new issues for all the things I work on. By making a new Issue we can avoid duplicating our efforts. - -## Development environment - -You need these things installed: - -- Node.js LTS -- For the VS Code extension, but recommended either way: - - Visual Studio Code stable - - The extension _TypeScript + Webpack Problem Matchers_ (`amodio.tsl-problem-matcher`) - -``` -# install dependencies -npm install -# confirm existing tests are running -npm test -``` - -### If using VS Code - -Go to the _Run and Debug_ pane in VS Code and run _Launch extension_. Rebuild the project and restart the debugging session to see changes take effect. - -If you get a warning, ensure you have the _TypeScript + Webpack Problem Matchers_ (`amodio.tsl-problem-matcher`) extension installed and active. - -## Architecture - -This extension consists of a [client for VS Code](https://github.com/wkillerud/some-sass/blob/main/vscode-extension) and a [language server](https://github.com/wkillerud/some-sass/blob/main/packages/language-server). The client starts the server on activation. - -The server then: - -1. [scans the workspace](https://github.com/wkillerud/some-sass/blob/main/packages/language-server/src/scanner.ts) -2. [parses](https://github.com/wkillerud/some-sass/blob/main/packages/language-server/src/parser/parser.ts) SCSS code -3. puts the parsed documents in a [in-memory context](https://github.com/wkillerud/some-sass/blob/main/packages/language-server/src/context-provider.ts) - -The client requests information from the server when needed, and notifies the server when a file changes. - -See [packages/langauge-server/src/server.ts](https://github.com/wkillerud/some-sass/blob/main/packages/langauge-server/src/server.ts) for the event listeners on the server side, and the [features folder in the server](https://github.com/wkillerud/some-sass/tree/main/packages/language-server/src/features) for code supporting the different features. - -```mermaid -sequenceDiagram - Client->>+Server: Extension activated - Server->Server: Scans for SCSS - Server->-Server: Parses SCSS - Client->>Server: Hover - Server-->>Client: Provide hover info - Client->>+Server: Changing file - Server->Server: Re-parse changed file - Server-->>-Client: Provide code suggestions -``` - -### Browser version - -This extension also works with VS Code in the browser. It works more or less the same as the regular Node version, except it doesn't have direct access to the file system. - -To work around this, [the server](https://github.com/wkillerud/some-sass/blob/main/packages/language-server/src/file-system-provider.ts) makes requests to [the client](https://github.com/wkillerud/some-sass/blob/main/vscode-extension/src/client.ts), which then uses the [FileSystem API](https://code.visualstudio.com/api/references/vscode-api#FileSystem) to work with files and directories, before sending the result back to the server. - -```mermaid -sequenceDiagram - Client->>Server: Extension activated - Server->>Client: Get list of files in the workspace - Client-->>Server: List of files - Server->>Client: Get contents of file - Client-->>+Server: Contents of file - Server->-Server: Parse SCSS - Client->>Server: Hover - Server-->>Client: Provide hover info -``` - -## Manual testing - -To test your changes in VS Code: - -- Go to the Run and Debug section -- Run the `Launch extension` configuration - -A new window should open with the title `[Extension Development Host]`. In this window you can open whatever project you want to use for testing. If you don't have one you can open the folder `vscode-extension/test/fixtures/` in this repository. - -Every time you make a change you need to restart `Launch extension`. - -Test your changes and see if they work. - -### Test the browser version - -This extension also works in the browser. Run `npm run start:web` to open VS Code in a browser and test your changes. - -Docs: [Test your web extension](https://code.visualstudio.com/api/extension-guides/web-extensions#test-your-web-extension) - -## Debugging - -### Debugging the Node (or regular) version - -You can set breakpoints to inspect what happens in your code. At time of writing you must set these breakpoints in the _compiled output_ in `vscode-extension/dist/node-server.js`. - -Open `vscode-extension/dist/node-server.js`, search for a function name close to where you want to debug, and place breakpoints where you would like. Then: - -- Go to the Run and Debug pane in VS Code. -- Select `Launch extension` and start debugging. - -You should see the code pause on your breakpoint. If not, try to place breakpoints elsewhere. Something unexpected may stop you from reaching your code. - -### Debugging the browser version - -You have two options when to debug the browser version: - -1. Run in VS Code: - - - Go to the Run and Debug section - - Run the _Launch Web Extension in VS Code_ configuration - - Open `vscode-extension/dist/browser-server.js`, search for a function name close to where you want to debug, and place breakpoints where you would like. - -2. Run in Chromium: - - In a terminal, run `npm run start:web` - - Open the Chromium developer tools and set breakpoints in the Sources pane. - -### Debugging unit tests - -Tests compile to `packages/language-server/out/test/`. If you want to debug using a unit test, you have to set breakpoints on the compiled output. - -Find your test, or the code you want to debug in the `packages/language-server/out/` folder, and set breakpoints. - -The extension Mocha Test Explorer (`hbenl.vscode-mocha-test-adapter`) is useful to launch individual tests in debug mode. Install the extension, open your unit test, and press the Debug button that should appear over your test. - -### Debugging integration tests - -Integration tests run in the Extension Development Host, and are more realistic than unit tests. However, sometimes they can be tricky to write. You can debug the tests themselves. - -This method of debugging is **not recommended** if you want to debug the functionality itself. Instead, debug using the Client + Server configuration explained above, and perform the test manually. - -If you want to debug the integration tests there are a few things to keep in mind, since tests run in this way use your main stable install of VS Code (not Insiders, like from the terminal): - -- You will need to install Vetur (`octref.vetur`), Astro (`astro-build.astro-vscode`) and Svelte for VS Code (`svelte.svelte-vscode`). -- You **must** use default settings for Some Sass. Tip: use the included Workspace Settings. -- To compile changes in test code, run `npm run compile` in the `e2e` directory. - -You set breakpoints in the compiled output. Integration tests compile to `vscode-extension/out/suite/`. Breakpoints can _only be set in test code_, meaning any code in the `e2e/out/` folder. - -Breakpoints set, go to the _Run and Debug_ pane in VS Code and run the Integration Tests configuration. - -### Debugging integration tests for the browser version - -Like [debugging integration tests](#debugging-integration-tests), do this when you want to debug the tests themselves rather than functionality. The web integration tests compile to in `web/src/suite/`. - -Set breakpoints in the compiled output (`web/dist/suite/index.js`). - -At time of writing you may have to set the breakpoints after the debugger has attached. I've had the best success rate clicking repeatedly to set the breakpoint. +The best place to get started is [the guide for new contributors](https://wkillerud.github.io/some-sass/contributing/new-contributors.html). ## Conventional commits @@ -157,10 +13,9 @@ Two assets are published: - The language server is published to npm - The VS Code extension is published to Visual Studio Marketplace and Open VSX -Keep both in mind when deciding whether a change is a patch, minor or major release. - | Commit message | Release type | | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs: added a guide for configuring sublime text` | Patch. Bugfix release, updates for runtime dependencies. | | `fix: update css-languageservice` | Patch. Bugfix release, updates for runtime dependencies. | | `feat: add support for show keyword in forward` | Minor. New feature release. | | `refactor: remove reduntant options for latest language version`

`BREAKING CHANGE: The scanImportedFiles option has been removed.` | Major. Breaking release, like removing an option or changing `engines` version.
(Note that the `BREAKING CHANGE: ` token must be in the footer of the commit) | diff --git a/README.md b/README.md index 66bae74d..2ced50ee 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,48 @@ # Some Sass -In this repo you'll find: +Some Sass is a [language server extension][langext] for [Visual Studio Code][vscode]. It brings improved code suggestions, documentation and code navigation for [SCSS][scss]. -- the [Some Sass](./vscode-extension#readme) extension for VS Code -- the [SCSS language server that powers it](./packages/language-server#readme) +Some features include: -The language server is [published independently to npm](https://www.npmjs.com/package/some-sass-language-server), and can be used with any editor that has a [language server protocol (LSP)](https://microsoft.github.io/language-server-protocol/) client. +- Full support for [`@use`][use] and [`@forward`][forward], including aliases, prefixes and hiding. +- Workspace-wide code navigation and refactoring, such as Rename Symbol. +- Rich documentation through [SassDoc][sassdoc]. +- Language features for [`%placeholders`][placeholder], both when using them and writing them. -## Editors with clients +![](./docs/src/images/highlight-reel.gif) + +## Get the extension + +You can find the extension here: + +- On the [Visual Studio Code Marketplace][vsmarketplace]. +- On the [Open VSX Registry][openvsx]. +- In the [Releases section on GitHub][ghreleases]. + +See the User guide section to learn more about what the extension can do. + +## Some Sass Language Server + +Some Sass is also a language server using the [Language Server Protocol (LSP)][lsp]. + +The language server is [published on npm][npm], and can be used with any editor that has an LSP client. See [Getting started](./language-server/getting-started.md) to learn more. + +### Editors with clients The language server has clients for - [Visual Studio Code](./vscode-extension#readme) - [Neovim](https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md#somesass_ls) + +[lsp]: https://microsoft.github.io/language-server-protocol/ +[npm]: https://www.npmjs.com/package/some-sass-language-server +[scss]: https://sass-lang.com/documentation/syntax/ +[use]: https://sass-lang.com/documentation/at-rules/use/ +[forward]: https://sass-lang.com/documentation/at-rules/forward/ +[langext]: https://code.visualstudio.com/api/language-extensions/language-server-extension-guide +[sassdoc]: http://sassdoc.com +[placeholder]: https://sass-lang.com/documentation/style-rules/placeholder-selectors/ +[vscode]: https://code.visualstudio.com/ +[vsmarketplace]: https://marketplace.visualstudio.com/items?itemName=SomewhatStationery.some-sass +[openvsx]: https://open-vsx.org/extension/SomewhatStationery/some-sass +[ghreleases]: https://github.com/wkillerud/some-sass/releases diff --git a/docs/src/README.md b/docs/src/README.md index 15a6792a..957693c3 100644 --- a/docs/src/README.md +++ b/docs/src/README.md @@ -1,3 +1,47 @@ # Some Sass -Hello, World! +Some Sass is a [language server extension][langext] for [Visual Studio Code][vscode]. It brings improved code suggestions, documentation and code navigation for [SCSS][scss]. + +Some features include: + +- Full support for [`@use`][use] and [`@forward`][forward], including aliases, prefixes and hiding. +- Workspace-wide code navigation and refactoring, such as Rename Symbol. +- Rich documentation through [SassDoc][sassdoc]. +- Language features for [`%placeholders`][placeholder], both when using them and writing them. + +![](./images/highlight-reel.gif) + +## Get the extension + +You can find the extension here: + +- On the [Visual Studio Code Marketplace][vsmarketplace]. +- On the [Open VSX Registry][openvsx]. +- In the [Releases section on GitHub][ghreleases]. + +See the User guide section to learn more about what the extension can do. + +## Some Sass Language Server + +Some Sass is also a language server using the [Language Server Protocol (LSP)][lsp]. + +The language server is [published on npm][npm], and can be used with any editor that has an LSP client. See [Getting started](./language-server/getting-started.md) to learn more. + +## Navigating the docs + +To navigate between pages you can click the arrow buttons, press the left and right arrow keys on your keyboard, or use the sidebar menu. + +To search click the magnifying class icon to the top left or press `s` on your keyboard. + +[lsp]: https://microsoft.github.io/language-server-protocol/ +[npm]: https://www.npmjs.com/package/some-sass-language-server +[scss]: https://sass-lang.com/documentation/syntax/ +[use]: https://sass-lang.com/documentation/at-rules/use/ +[forward]: https://sass-lang.com/documentation/at-rules/forward/ +[langext]: https://code.visualstudio.com/api/language-extensions/language-server-extension-guide +[sassdoc]: http://sassdoc.com +[placeholder]: https://sass-lang.com/documentation/style-rules/placeholder-selectors/ +[vscode]: https://code.visualstudio.com/ +[vsmarketplace]: https://marketplace.visualstudio.com/items?itemName=SomewhatStationery.some-sass +[openvsx]: https://open-vsx.org/extension/SomewhatStationery/some-sass +[ghreleases]: https://github.com/wkillerud/some-sass/releases diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 43a28d04..172adbef 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -1,3 +1,37 @@ # Summary [Introduction](README.md) + +# User guide + +- [IntelliSense](user-guide/completions.md) +- [Navigation](user-guide/navigation.md) +- [Hover info](user-guide/hover.md) +- [Refactoring](user-guide/refactoring.md) +- [Diagnostics](user-guide/diagnostics.md) +- [Color decorators](user-guide/color.md) +- [Settings](user-guide/settings.md) + +# Use outside VS Code + +- [Getting started](language-server/getting-started.md) +- [Configure a client](language-server/configure-a-client.md) +- [Existing clients](language-server/existing-clients.md) + - [Neovim](language-server/neovim.md) + +# Contributing to Some Sass + +- [New contributors](contributing/new-contributors.md) + - [Extensions for VS Code](contributing/extensions-for-vs-code.md) + - [Language Server Protocol](contributing/language-server-protocol.md) +- [Development environment](contributing/development-environment.md) +- [Architecture](contributing/architecture.md) +- [Building](contributing/building.md) +- [Automated tests](contributing/automated-tests.md) + - [Test coverage](contributing/test-coverage.md) +- [Debugging](contributing/debugging.md) + - [Debugging in the browser](contributing/debugging-in-browser.md) + - [Debugging unit tests](contributing/debugging-unit-tests.md) + - [Debugging end-to-end tests](contributing/debugging-e2e-tests.md) +- [Releasing new versions](contributing/releases.md) +- [Writing documentation](contributing/writing-documentation.md) diff --git a/docs/src/contributing/architecture.md b/docs/src/contributing/architecture.md new file mode 100644 index 00000000..bad92786 --- /dev/null +++ b/docs/src/contributing/architecture.md @@ -0,0 +1,47 @@ +# Architecture + +Being a [language server extension](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide), Some Sass consists of a [client](https://github.com/wkillerud/some-sass/blob/main/vscode-extension) and a [server](https://github.com/wkillerud/some-sass/blob/main/packages/language-server). The client starts the server when it opens a file with SCSS. This is called activation. + +From there everything happens via [messages](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/). + +![](../images/architecture/node.png) + +Some Sass also works with Visual Studio Code in the browser. It works more or less the same as the regular Node version, except it doesn't have direct access to the file system. + +To work around this, the server makes requests to the client, which then uses the [FileSystem API](https://code.visualstudio.com/api/references/vscode-api#FileSystem) to work with files and directories, before sending the result back to the server. + +![](../images/architecture/browser.png) + +## Server architecture + +The code for the server is divided in three packages: + +1. Language server +2. Language services +3. VS Code CSS language service – the SCSS parser and language features that are included in VS Code. + +### Language server + +This package handles communication with the language client, and not much else. + +### Language services + +This is where you find the functionality of the language server, organized in classes that inherit from a base `LanguageFeature` class. + +All features will parse the given document, but parses are cached for performance reasons. The flow looks something like this: + +![](../images/architecture/parser-cache.png) + +A language feature takes a text document and will try to get a data structure representing the document's Sass semantics such as variables, classes, functions and mixins. + +The first time this happens the document gets sent to the parser, which returns this data structure. That result is cached. The next time a feature tries to get the data structure it will read from the cache. The cache entry is removed when the document changes so the new document can be parsed. + +In addition to the parsed document, the cache also holds: + +- The results from resolving links (`@use`, `@forward`, `@import`). +- The results from parsing the document's Sassdoc. +- The document's symbols, as returned from `findDocumentSymbols()`. + +### VS Code CSS language service + +The project includes a private fork of the `vscode-css-languageservice` module. The original `vscode-css-languageservice` powers the CSS, SCSS and Less features in Visual Studio Code. Some Sass uses this module's parser and some of its language features. It's kept as a separate package to simplify updates, and to make it easier to send patches upstream. diff --git a/docs/src/contributing/automated-tests.md b/docs/src/contributing/automated-tests.md new file mode 100644 index 00000000..02c5178b --- /dev/null +++ b/docs/src/contributing/automated-tests.md @@ -0,0 +1,42 @@ +# Automated tests + +This document describes how to run the automated tests and what the different tests cover. + +## Unit tests + +All packages in `packages/` have unit tests. To run them: + +```sh +npm run test +``` + +The main test runner is [Vitest]. `vscode-css-languageservice` uses [Mocha]. + +Unit tests typically cover either a utility function or a language feature such as `doHover`. For language features the tests are typically split in several files, each focusing on part of the functionality of the language feature. + +## End-to-end tests + +The Visual Studio Code extension includes end-to-end tests. To run them: + +```sh +npm run test:e2e +``` + +It also includes end-to-end tests for the web extension. To run them: + +```sh +npm run test:web +``` + +The end-to-end tests have some overlap with the unit tests for language features, but are useful to confirm the communication between client and server works as expected. + +## Run all tests + +A convenience script lets you run all unit tests and end-to-end tests: + +```sh +npm run test:all +``` + +[Vitest]: https://vitest.dev/ +[Mocha]: https://mochajs.org/ diff --git a/docs/src/contributing/building.md b/docs/src/contributing/building.md new file mode 100644 index 00000000..c769501f --- /dev/null +++ b/docs/src/contributing/building.md @@ -0,0 +1,34 @@ +# Building + +This document describes how to build Some Sass. + +## The workspace + +This repo is an `npm` [workspace] with several packages listed in the `"workspaces"` key in the root `package.json`. The packages are listed in order with the "base" package at the top and the published language server and extension toward the bottom. + +## A full build + +Run this command at the root level of the repo to build all packages: + +```sh +npm run build +``` + +This will build all packages and the Visual Studio Code extension. + +### Partial builds + +Each package has its own `build` command. If you made a change in the `language-server` folder you only have to build that and the `vscode-extension` packages. Of course you can allways do a full build if you want. + +## Clean builds + +If something unexpected happens with your build you can do a clean build: + +```sh +npm run clean +npm run build +``` + +This deletes any old build you may have before doing a new build. + +[workspace]: https://docs.npmjs.com/cli/v10/using-npm/workspaces diff --git a/docs/src/contributing/debugging-e2e-tests.md b/docs/src/contributing/debugging-e2e-tests.md new file mode 100644 index 00000000..71fe89a2 --- /dev/null +++ b/docs/src/contributing/debugging-e2e-tests.md @@ -0,0 +1,20 @@ +# Debugging end-to-end tests + +End-to-end tests run in Visual Studio Code and are helpful to ensure the user experience is as we expect. However, they can be tricky to write sometimes. This document describes how you can debug the tests themselves. + +## Prepare Visual Studio Code + +The debugger runs tests in your version of Visual Studio Code, not VS Code insiders like when running the tests from the command line. You need to use the default settings for Some Sass (use the included workspace settings from the repo). + +[exthost]: https://code.visualstudio.com/api/advanced-topics/extension-host + +## Launch the debugger + +Go to the [Run and Debug pane][vsdebug] in VS Code and run Launch integration tests. The tests will start running immediately, and the window closes when the test run is finished. + +You can set breakpoints directly in the test code in `vscode-extension/test/e2e/`. + +![](../images/debugging/debugging-e2e-test.png) + +[jsapi]: https://code.visualstudio.com/api/references/vscode-api +[vsdebug]: https://code.visualstudio.com/docs/editor/debugging diff --git a/docs/src/contributing/debugging-in-browser.md b/docs/src/contributing/debugging-in-browser.md new file mode 100644 index 00000000..f42cdfb3 --- /dev/null +++ b/docs/src/contributing/debugging-in-browser.md @@ -0,0 +1,29 @@ +# Debugging in the browser + +You can use Some Sass with Visual Studio Code running in the browser. This document describes how you can test Some Sass running in Chromium. + +## Run the test command + +In a terminal, run: + +```sh +npm run start:web +``` + +This opens Visual Studio Code running as a [web extension host][exthost] in Chromium. The language server runs as a [web worker][worker], and is started when you open a Sass file. + +Open the Sass project you're using to test in the extension host window. If you don't have one you can open the folder `vscode-extension/test/fixtures/` in this repository. + +## Navigating the Chromium developer tools + +Open the developer tools and click the [Sources tab][sources] to set breakpoints. + +The web worker for `browser-server.js` is in the left panel of the Sources tab. If you don't see it, make sure you open a Sass file to activate the extension. + +![](../images/debugging/chromium-debugger.png) + +In the WorkerExtensionHost you'll see `localhost:3000` and `serverExportVar`. You may find it easier to navigate in `severExportVar` since it uses source maps to match the source code of the language server package. + +[exthost]: https://code.visualstudio.com/api/advanced-topics/extension-host +[sources]: https://developer.chrome.com/docs/devtools/sources +[worker]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers diff --git a/docs/src/contributing/debugging-unit-tests.md b/docs/src/contributing/debugging-unit-tests.md new file mode 100644 index 00000000..06af6cab --- /dev/null +++ b/docs/src/contributing/debugging-unit-tests.md @@ -0,0 +1,19 @@ +# Debugging unit tests + +This document assumes you use Visual Studio Code and have the [Vitest](https://marketplace.visualstudio.com/items?itemName=vitest.explorer) extension. + +Open a unit test file (excluding tests in `vscode-css-languageservice`, which use Mocha) and find the test you want to debug. + +You should see an icon in the gutter. To debug the test, right click and select Debug test. + +![](../images/debugging/debug-individual-test.gif) + +## Test-driven development + +When you work on a language feature it's useful to set up a test and use that while developing. + +The tests have an in-memory file system provider, so you can test how a language feature works with Sass code without making files on disk. + +By using the Vitest debugger you can shorten the feedback loop significantly compared to building the whole project and testing manually in Visual Studio Code. + +![](../images/debugging/debugging-unit-test.png) diff --git a/docs/src/contributing/debugging.md b/docs/src/contributing/debugging.md new file mode 100644 index 00000000..8ab13837 --- /dev/null +++ b/docs/src/contributing/debugging.md @@ -0,0 +1,31 @@ +# Debugging + +This page assumes you're using Visual Studio Code as the debugger. Go to the [Run and Debug pane][vsdebug] in VS Code to find the different launch configurations. + +- Launch extension +- Launch web extension +- Launch integration tests +- Launch web integration tests + +## Launch extension + +This opens a new window of Visual Studio Code running as a [local extension host][exthost]. Open the Sass project you're using to test in the extension host window. If you don't have one you can open the folder `vscode-extension/test/fixtures/` in this repository. + +Find `node-server.js` in the `vscode-extension/dist/` folder to set breakpoints. + +![](../images/debugging/launch-extension.png) + +If you make changes to the code you need to run `npm run build` and restart the debugger. + +## Launch web extension + +This opens a new window of Visual Studio Code running as a [web extension host][exthost]. Open the Sass project you're using to test in the extension host window. If you don't have one you can open the folder `vscode-extension/test/fixtures/` in this repository. + +Find `browser-server.js` in the `vscode-extension/dist/` folder to set breakpoints. + +![](../images/debugging/launch-browser-extension.png) + +If you make changes to the code you need to run `npm run build` and restart the debugger. + +[exthost]: https://code.visualstudio.com/api/advanced-topics/extension-host +[vsdebug]: https://code.visualstudio.com/docs/editor/debugging diff --git a/docs/src/contributing/development-environment.md b/docs/src/contributing/development-environment.md new file mode 100644 index 00000000..8552dd08 --- /dev/null +++ b/docs/src/contributing/development-environment.md @@ -0,0 +1,37 @@ +# Development environment + +The language server is written in TypeScript and runs both in Node and the browser. While the server can be used outside of Visual Studio Code, it's recommended to use VS Code for development. + +You need: + +- A long-term support version of [Node.js](https://nodejs.org/en) +- [Visual Studio Code](https://code.visualstudio.com/) + +Recommended extensions: + +- [Vitest](https://marketplace.visualstudio.com/items?itemName=vitest.explorer) to help run and debug individual tests. + +To preview the documentation you need [mdbook](https://rust-lang.github.io/mdBook/guide/installation.html). If you're on macOS and use [Homebrew](https://brew.sh) you can `brew install mdbook`. + +## Getting started + +Clone the repo and install dependencies: + +```sh +git clone git@github.com:wkillerud/some-sass.git +cd some-sass +npm install +``` + +Run the build and automated tests. Some of the automated tests open a new window and run in Visual Studio Code Insiders. + +```sh +npm run build +npm run test:all +``` + +## Next steps + +You may want to have a look at the [architecture](./architecture.md) of the language server. Most of the functionality of the language server is in the `language-services` package in `packages/`. + +[Test-driven development](./debugging-unit-tests.md) with Vitest and the VS Code debugger gives the shortest feedback loop. diff --git a/docs/src/contributing/extensions-for-vs-code.md b/docs/src/contributing/extensions-for-vs-code.md new file mode 100644 index 00000000..d00a45a2 --- /dev/null +++ b/docs/src/contributing/extensions-for-vs-code.md @@ -0,0 +1,12 @@ +# Extensions for Visual Studio Code + +This is not required reading, but if you want to learn more about extension development these links are a good place to start. + +- [Your first extension](https://code.visualstudio.com/api/get-started/your-first-extension) + +Some Sass is a language server extension. It can also run in the browser. The project has automated end-to-end tests for both Electron and the browser. + +- [Language server extension guide](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide) +- [Web extensions](https://code.visualstudio.com/api/extension-guides/web-extensions) + +Releases are published using Semantic Release whenever pull requests are merged. diff --git a/docs/src/contributing/language-server-protocol.md b/docs/src/contributing/language-server-protocol.md new file mode 100644 index 00000000..78b12638 --- /dev/null +++ b/docs/src/contributing/language-server-protocol.md @@ -0,0 +1,18 @@ +# Language Server Protocol + +From [Why Language Server?][why-lsp]: + +> [The] Language Server Protocol [...] standardizes the communication between language tooling and code editor. This way [...] any LSP-compliant language toolings can integrate with multiple LSP-compliant code editors, and any LSP-compliant code editors can easily pick up multiple LSP-compliant language toolings. LSP is a win for both language tooling providers and code editor vendors! + +In other words, LSP lets you build the language support tools once and run in any editor that has an LSP client. + +For the most part you don't need to worry about the implementation details of the LSP. Microsoft's [TypeScript implementation][implementation] handles the nitty-gritty. + +## Language features + +The Visual Studio Code documentation for [Programatic language features][features] gives a good sense of what's possible with LSP. If you want to dive deep, the [specification] lists all the messages and their parameters. + +[why-lsp]: https://code.visualstudio.com/api/language-extensions/language-server-extension-guide#why-language-server +[features]: https://code.visualstudio.com/api/language-extensions/programmatic-language-features +[implementation]: https://github.com/microsoft/vscode-languageserver-node/tree/main +[specification]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/ diff --git a/docs/src/contributing/new-contributors.md b/docs/src/contributing/new-contributors.md new file mode 100644 index 00000000..7d16e542 --- /dev/null +++ b/docs/src/contributing/new-contributors.md @@ -0,0 +1,20 @@ +# New contributors + +Thank you for showing an interest in contributing 🌟 + +If you've never worked on Some Sass you're in the right place. There are several ways you can help. + +- Share your setup in Discussions' [Show and tell](https://github.com/wkillerud/some-sass/discussions/categories/show-and-tell). +- Research [open issues](https://github.com/wkillerud/some-sass/issues) to find out what needs to be done. +- [Configure language clients](../language-server/getting-started.md) for more editors. +- Improve this documentation, add screenshots or recordings. +- Add unit tests or end-to-end tests where missing. +- Volunteer to fix bugs or add missing features. + +Have a look at [Writing documentation](./writing-documentation.md) if you want to work on the docs. + +Diving in to code? You may be interested in: + +- Primers on [extensions for Visual Studio Code](./extensions-for-vs-code.md) and the [Language Server Protocol](./language-server-protocol.md) +- How you set up your [development environment](./development-environment.md). +- Before long you'll need to do some [debugging](./debugging.md). diff --git a/docs/src/contributing/releases.md b/docs/src/contributing/releases.md new file mode 100644 index 00000000..ba1916c7 --- /dev/null +++ b/docs/src/contributing/releases.md @@ -0,0 +1,52 @@ +# Releasing new versions + +This document describes how to release a new version of Some Sass. + +## Semantic Release + +This repository uses [`semantic-release`][semrel] (technically [`multi-semantic-release`][multisemrel]) to automatically publish changes merged to `main`. + +- The [language service package][lsnpm] is published to `npm`. +- The Visual Studio Code extension is published to: + - [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SomewhatStationery.some-sass) + - [Open VSX](https://open-vsx.org/extension/SomewhatStationery/some-sass) + +Semantic Release works by [conventional commits][conventional]. Which version is released depends on how you write the commit message. + +| Commit message | Release type | +| ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs: add guide for configuring sublime` | No new release. | +| `fix: update css-languageservice` | Patch. Bugfix release, updates for runtime dependencies. | +| `feat: add support for show keyword in forward` | Minor. New feature release. | +| `refactor: remove reduntant options for latest language version`

`BREAKING CHANGE: The scanImportedFiles option has been removed.` | Major. Breaking release, like removing an option or changing `engines` version.
(Note that the `BREAKING CHANGE: ` token must be in the footer of the commit) | + +### Manual fallback + +For `npm` packages: + +```sh +npm version [major|minor|patch] +npm publish +``` + +For the VS Code extension: + +```sh +vsce package +``` + +Then publish manually via Visual Studio [Marketplace], [Open VSX] and GitHub Releases (attach the `.vsix` file to the release). + +References: + +- [npm version](https://docs.npmjs.com/cli/v10/commands/npm-version) +- [npm publish](https://docs.npmjs.com/cli/v10/commands/npm-publish) +- [vsce](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) +- [osvx](https://github.com/eclipse/openvsx/wiki/Publishing-Extensions) + +[semrel]: https://github.com/semantic-release/semantic-release#how-does-it-work +[multisemrel]: https://github.com/qiwi/multi-semantic-release +[conventional]: https://www.conventionalcommits.org/en/v1.0.0/#summary +[Open VSX]: https://open-vsx.org +[Marketplace]: https://marketplace.visualstudio.com/ +[lsnpm]: https://www.npmjs.com/package/some-sass-language-server diff --git a/docs/src/contributing/test-coverage.md b/docs/src/contributing/test-coverage.md new file mode 100644 index 00000000..9d266588 --- /dev/null +++ b/docs/src/contributing/test-coverage.md @@ -0,0 +1,15 @@ +# Test coverage + +While there's no target for test coverage in the project, coverage reports can be useful to see if there's a corner case that should be tested. + +## Generate a coverage report + +Coverage reports are generated per package. To generate a report run: + +```sh +npm run coverage +``` + +Coverage reports are printed to the terminal. HTML versions you can open in a browser get generated in each package's directory. Look for a `coverage/` folder and open `index.html` in your browser. + +![](../images/tests/coverage-report.png) diff --git a/docs/src/contributing/writing-documentation.md b/docs/src/contributing/writing-documentation.md new file mode 100644 index 00000000..7fdc32d2 --- /dev/null +++ b/docs/src/contributing/writing-documentation.md @@ -0,0 +1,104 @@ +# Writing documentation + +Help others get the most out of their software by contributing to documentation. + +- Do you have a pro tip you want to share? +- Did you take a screenshot that can help visualize something? + +New contributors are especially welcome to write documentation and add examples. As a new contributor you know best what is confusing or difficult. + +## Quick start + +Fork and clone the repository from [GitHub][repo]. The documentation is in the `docs/src/` folder. + +```sh +git clone git@github.com:wkillerud/some-sass.git +``` + +Once you're happy, commit the changes and prefix the commit message with `docs:` + +```sh +git commit -m "docs: add GIF demoing Go to definition" +``` + +### Preview the documentation + +You need [mdbook] to preview the documentation on your machine. + +If you're on macOS and use [Homebrew][brew] you can `brew install mdbook`. Otherwise, check the [mdbook user guide](https://rust-lang.github.io/mdBook/guide/installation.html). + +Once you have it installed, open a terminal and navigate to the `docs/` directory. + +```sh +cd docs +mdbook serve --open +``` + +Changes you make in Markdown files in `docs/src/` are live updated in the browser. + +## Who we are writing for + +We write for three different groups: + +1. Stylesheet developers who use Some Sass +2. Users of editors other than Visual Studio Code who want to use Some Sass +3. Developers who want to fix a bug or add to Some Sass + +Each group should find sections and chapters in the sidebar to help guide them to what they are looking for. + +### Writing for stylesheet developers + +A stylesheet developer doesn't need to learn about the inner workings of Some Sass. They are here to learn what the tool can do, or because something is not matching their expectations. + +Introduce the reader to recommended settings early, including settings for the editor itself. + +#### Screenshots and recordings + +Show what you explained in writing using one or more [screenshots](https://rust-lang.github.io/mdBook/format/markdown.html#images) if you can. Media should come after the paragraph explaining a feature. + +If something is better conveyed in a screen recording, prefer an image format like GIF over video. Recordings should be short and showcase one thing. The quality must be good enough that text is legible. For an example, see the [IntelliSense](https://code.visualstudio.com/docs/editor/intellisense) documentation in Visual Studio Code. + +### Writing for users of editors other than Visual Studio Code + +Users of editors other than Visual Studio Code who want to use Some Sass need to know: + +1. That it's possible to do so +2. How to do it + +Assume the reader is new to the language server protocol and has never configured a language server client. Examples are a great help here. + +### Writing for developers who want to change or add to Some Sass + +Here we need to consider both new and returning developers. + +#### Onboarding + +For new developers: + +- Assume the reader has never written an extension for Visual Studio Code. +- Assume the reader is new to the Language Server Protocol. + +Introduce new contributors to these topics, and link to external material if they want to learn more. Also introduce the architecture so they have a better idea of where to start. Visualize with diagrams. + +Explain how they should set up their development environment to be productive. + +#### Guides + +All developers (including your future self) could use a guide for common tasks like testing and debugging. This documentation can assume the reader completed the onboarding. + +## Writing style guide + +These are more guidelines than actual rules. + +- The first time you reference Visual Studio Code below a heading, write out the full name. After that you can use VS Code. +- Prefer [Excalidraw][excalidraw] for diagrams, exported as PNG and included in Markdown as [an image][images]. + +The [Hemingway Editor](https://hemingwayapp.com/) is a free tool to help edit your writing. You can also refer to these [notes from Google Technical Writing One][gtechwriting] if you'd like. That said, don't worry too much about the details. + +[brew]: https://brew.sh +[mdbook]: https://rust-lang.github.io/mdBook/ +[installation]: https://rust-lang.github.io/mdBook/guide/installation.html +[repo]: https://github.com/wkillerud/some-sass +[excalidraw]: https://excalidraw.com +[images]: https://rust-lang.github.io/mdBook/format/markdown.html#images +[gtechwriting]: https://www.williamkillerud.com/blog/notes-from-google-technical-writing-one/ diff --git a/docs/src/images/README.md b/docs/src/images/README.md new file mode 100644 index 00000000..285b675e --- /dev/null +++ b/docs/src/images/README.md @@ -0,0 +1,6 @@ +# Images + +To make a new highlight reel: + +- record individual GIFs, for example using [CleanShot X](https://cleanshot.com/) +- using [ImageMagick](https://formulae.brew.sh/formula/imagemagick), run `convert first.gif second.gif third.gif highlight-reel.gif` diff --git a/docs/src/images/architecture/browser.png b/docs/src/images/architecture/browser.png new file mode 100644 index 00000000..c5f44f36 Binary files /dev/null and b/docs/src/images/architecture/browser.png differ diff --git a/docs/src/images/architecture/node.png b/docs/src/images/architecture/node.png new file mode 100644 index 00000000..03cc5819 Binary files /dev/null and b/docs/src/images/architecture/node.png differ diff --git a/docs/src/images/architecture/parser-cache.png b/docs/src/images/architecture/parser-cache.png new file mode 100644 index 00000000..0f353e5a Binary files /dev/null and b/docs/src/images/architecture/parser-cache.png differ diff --git a/docs/src/images/debugging/chromium-debugger.png b/docs/src/images/debugging/chromium-debugger.png new file mode 100644 index 00000000..c929cbb4 Binary files /dev/null and b/docs/src/images/debugging/chromium-debugger.png differ diff --git a/docs/src/images/debugging/debug-individual-test.gif b/docs/src/images/debugging/debug-individual-test.gif new file mode 100644 index 00000000..c0b26ffe Binary files /dev/null and b/docs/src/images/debugging/debug-individual-test.gif differ diff --git a/docs/src/images/debugging/debugging-e2e-test.png b/docs/src/images/debugging/debugging-e2e-test.png new file mode 100644 index 00000000..1a830f86 Binary files /dev/null and b/docs/src/images/debugging/debugging-e2e-test.png differ diff --git a/docs/src/images/debugging/debugging-unit-test.png b/docs/src/images/debugging/debugging-unit-test.png new file mode 100644 index 00000000..dffe30dd Binary files /dev/null and b/docs/src/images/debugging/debugging-unit-test.png differ diff --git a/docs/src/images/debugging/launch-browser-extension.png b/docs/src/images/debugging/launch-browser-extension.png new file mode 100644 index 00000000..34a0a1de Binary files /dev/null and b/docs/src/images/debugging/launch-browser-extension.png differ diff --git a/docs/src/images/debugging/launch-extension.png b/docs/src/images/debugging/launch-extension.png new file mode 100644 index 00000000..900095dc Binary files /dev/null and b/docs/src/images/debugging/launch-extension.png differ diff --git a/docs/src/images/highlight-reel.gif b/docs/src/images/highlight-reel.gif new file mode 100644 index 00000000..1673ce32 Binary files /dev/null and b/docs/src/images/highlight-reel.gif differ diff --git a/docs/src/images/tests/coverage-report.png b/docs/src/images/tests/coverage-report.png new file mode 100644 index 00000000..99113e1a Binary files /dev/null and b/docs/src/images/tests/coverage-report.png differ diff --git a/docs/src/images/usage/color-decorators.png b/docs/src/images/usage/color-decorators.png new file mode 100644 index 00000000..8194965a Binary files /dev/null and b/docs/src/images/usage/color-decorators.png differ diff --git a/docs/src/images/usage/diagnostics-deprecated.png b/docs/src/images/usage/diagnostics-deprecated.png new file mode 100644 index 00000000..51cf6fcd Binary files /dev/null and b/docs/src/images/usage/diagnostics-deprecated.png differ diff --git a/docs/src/images/usage/extract.gif b/docs/src/images/usage/extract.gif new file mode 100644 index 00000000..e88f5c50 Binary files /dev/null and b/docs/src/images/usage/extract.gif differ diff --git a/docs/src/images/usage/find-references.gif b/docs/src/images/usage/find-references.gif new file mode 100644 index 00000000..3e3803fa Binary files /dev/null and b/docs/src/images/usage/find-references.gif differ diff --git a/docs/src/images/usage/go-to-definition.gif b/docs/src/images/usage/go-to-definition.gif new file mode 100644 index 00000000..f8d7fbfe Binary files /dev/null and b/docs/src/images/usage/go-to-definition.gif differ diff --git a/docs/src/images/usage/import-completions.png b/docs/src/images/usage/import-completions.png new file mode 100644 index 00000000..3189c37a Binary files /dev/null and b/docs/src/images/usage/import-completions.png differ diff --git a/docs/src/images/usage/pkg-imports.png b/docs/src/images/usage/pkg-imports.png new file mode 100644 index 00000000..26076d10 Binary files /dev/null and b/docs/src/images/usage/pkg-imports.png differ diff --git a/docs/src/images/usage/placeholder-declare.png b/docs/src/images/usage/placeholder-declare.png new file mode 100644 index 00000000..eb416ff5 Binary files /dev/null and b/docs/src/images/usage/placeholder-declare.png differ diff --git a/docs/src/images/usage/placeholder-extend.png b/docs/src/images/usage/placeholder-extend.png new file mode 100644 index 00000000..84ea4f82 Binary files /dev/null and b/docs/src/images/usage/placeholder-extend.png differ diff --git a/docs/src/images/usage/rename-symbol.gif b/docs/src/images/usage/rename-symbol.gif new file mode 100644 index 00000000..1e9d3a50 Binary files /dev/null and b/docs/src/images/usage/rename-symbol.gif differ diff --git a/docs/src/images/usage/sass-built-in-hover.png b/docs/src/images/usage/sass-built-in-hover.png new file mode 100644 index 00000000..9ebfb84e Binary files /dev/null and b/docs/src/images/usage/sass-built-in-hover.png differ diff --git a/docs/src/images/usage/sassdoc-annotation-hover.png b/docs/src/images/usage/sassdoc-annotation-hover.png new file mode 100644 index 00000000..f851c716 Binary files /dev/null and b/docs/src/images/usage/sassdoc-annotation-hover.png differ diff --git a/docs/src/images/usage/sassdoc-block.gif b/docs/src/images/usage/sassdoc-block.gif new file mode 100644 index 00000000..f685feb7 Binary files /dev/null and b/docs/src/images/usage/sassdoc-block.gif differ diff --git a/docs/src/images/usage/sassdoc-hover.png b/docs/src/images/usage/sassdoc-hover.png new file mode 100644 index 00000000..63150acc Binary files /dev/null and b/docs/src/images/usage/sassdoc-hover.png differ diff --git a/docs/src/images/usage/signature-helper.gif b/docs/src/images/usage/signature-helper.gif new file mode 100644 index 00000000..bc91bc22 Binary files /dev/null and b/docs/src/images/usage/signature-helper.gif differ diff --git a/docs/src/images/usage/string-literal-union-type.png b/docs/src/images/usage/string-literal-union-type.png new file mode 100644 index 00000000..dbaa61db Binary files /dev/null and b/docs/src/images/usage/string-literal-union-type.png differ diff --git a/images/suggestions-mixins.gif b/docs/src/images/usage/suggestions-mixins.gif similarity index 100% rename from images/suggestions-mixins.gif rename to docs/src/images/usage/suggestions-mixins.gif diff --git a/docs/src/language-server/configure-a-client.md b/docs/src/language-server/configure-a-client.md new file mode 100644 index 00000000..8b6d8f6a --- /dev/null +++ b/docs/src/language-server/configure-a-client.md @@ -0,0 +1,12 @@ +# Configure a client + +An editor needs a language client for the [Language Server Protocol (LSP)][lsp] to use a language server. + +To configure a client for an editor that doesn't have one yet, check the documentation for your editor to see if it supports LSP natively. If not, there may be an extension, add-on or plugin that adds support for LSP. + +## Existing clients + +This list of [language client implementations][languageclients] may be a helpful starting point. You may also want to look at [existing clients](./existing-clients.md). + +[lsp]: https://microsoft.github.io/language-server-protocol/ +[languageclients]: https://microsoft.github.io/language-server-protocol/implementors/tools/ diff --git a/docs/src/language-server/existing-clients.md b/docs/src/language-server/existing-clients.md new file mode 100644 index 00000000..2360af4c --- /dev/null +++ b/docs/src/language-server/existing-clients.md @@ -0,0 +1,5 @@ +# Existing clients + +Editors with ready-configured clients, maintained by the community. + +- [Neovim](./neovim.md) diff --git a/docs/src/language-server/getting-started.md b/docs/src/language-server/getting-started.md new file mode 100644 index 00000000..f846aea2 --- /dev/null +++ b/docs/src/language-server/getting-started.md @@ -0,0 +1,34 @@ +# Use Some Sass outside Visual Studio Code + +Some Sass is a language server using the [Language Server Protocol (LSP)][lsp]. + +The language server is [published independently to npm][npm], and can be used with any editor that has an LSP client. The server is designed to run alongside the [VS Code CSS language server](https://github.com/hrsh7th/vscode-langservers-extracted). + +## Getting started + +You can install the language server with `npm`: + +```sh +npm install --global some-sass-language-server +``` + +Then start the language server like so: + +```sh +some-sass-language-server --stdio +``` + +**Options** + +`--debug` – runs the development build of the language server, helpful to get more context if the server crashes + +### Settings + +The language server requests [settings](../user-guide/settings.md) via `workspace/configuration` on the `somesass` key. All fields are optional. + +## Configure a client + +The next step is to [configure a language client](./configure-a-client.md). + +[lsp]: https://microsoft.github.io/language-server-protocol/ +[npm]: https://www.npmjs.com/package/some-sass-language-server diff --git a/docs/src/language-server/neovim.md b/docs/src/language-server/neovim.md new file mode 100644 index 00000000..4c92c625 --- /dev/null +++ b/docs/src/language-server/neovim.md @@ -0,0 +1,8 @@ +# Neovim + +Neovim has a ready-to-use client configuration maintained by the community. + +There are two options: + +- [lspconfig](https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md#somesass_ls) +- [mason](https://mason-registry.dev/registry/list#some-sass-language-server) diff --git a/docs/src/user-guide/color.md b/docs/src/user-guide/color.md new file mode 100644 index 00000000..2a5c213a --- /dev/null +++ b/docs/src/user-guide/color.md @@ -0,0 +1,9 @@ +# Color decorators + +This document describes the color decorators features of Some Sass. + +## Decorators for Sass variables + +Some Sass adds decorators for color variables where they are used. + +![](../images/usage/color-decorators.png) diff --git a/docs/src/user-guide/completions.md b/docs/src/user-guide/completions.md new file mode 100644 index 00000000..95d76811 --- /dev/null +++ b/docs/src/user-guide/completions.md @@ -0,0 +1,84 @@ +# IntelliSense + +This page describes what Some Sass adds to [code completions][intellisense], also called IntelliSense in Visual Studio Code. + +## Namespaced suggestions + +With the [recommended settings](./settings.md#recommended-settings), suggestions get limited to only the symbols available in that namespace. Code completions from Some Sass has full support for: + +- aliasing (`@use "foo" as f;`) +- prefixes (`@forward "foo" as bar-*;`) +- [hide/show][visibility] + +Sass [built-in modules][builtin] (such as `"sass:map"`) get the same treatment when imported with `@use`. + +![](../images/usage/suggestions-mixins.gif) + +## SassDoc block + +Some Sass works best when you document your codebase with [SassDoc]. To make it easier you can let Some Sass generate a skeleton by typing `///` and choosing SassDoc block. + +![](../images/usage/sassdoc-block.gif) + +## SassDoc string literal union types + +If you have a function or mixin that expects only a set of string values you can document them with a string literal union type. Some Sass will present the list of choices when you use them. + +```scss +/// Get a timing value for use in animations. +/// @param {"sonic" | "link" | "homer" | "snorlax"} $mode - The timing you want +/// @return {String} - the timing value in ms +@function timing($mode) { + @if map.has-key($_timings, $mode) { + @return map.get($_timings, $mode); + } @else { + @error 'Unable to find a mode for #{$mode}'; + } +} +``` + +![](../images/usage/string-literal-union-type.png) + +## Signature helpers + +For functions and mixins, Some Sass gives you signature helpers. These are small popups that show information about the mixin or function's parameters, and which one you are about to enter. + +![](../images/usage/signature-helper.gif) + +## Placeholder selectors + +There are two ways which Some Sass helps with code suggestions for [placeholder selectors][placeholders]: + +- `%placeholder`-first workflows +- `@extend`-first workflows + +### Placeholder first + +This is where you write a placeholder selector first, and then `@extend` it somewhere else in your code. Some Sass will suggest all available placeholder selectors when you type `@extend %`. + +![](../images/usage/placeholder-extend.png) + +### Extend first + +This workflow can be useful in scenarios where the selectors change, but the style should stay the same. You define a stylesheet with the selectors, `@extend` stable placeholder selectors, and then implement those placeholders. This workflow is for instance used in parts of the Discord theming community. + +![](../images/usage/placeholder-declare.png) + +## Import suggestions + +When you write imports Some Sass reads the file system to help you complete the string. + +![](../images/usage/import-completions.png) + +### `pkg:` imports + +You can get a list of packages in the closest `node_modules` folder by manually [triggering IntelliSense][manual] (Ctrl + Space) to help you write `pkg:` imports. + +![](../images/usage/pkg-imports.png) + +[intellisense]: https://code.visualstudio.com/docs/editor/intellisense#_types-of-completions +[manual]: https://code.visualstudio.com/docs/editor/intellisense#_intellisense-features +[SassDoc]: http://sassdoc.com/annotations#description +[placeholders]: https://sass-lang.com/documentation/style-rules/placeholder-selectors/ +[visibility]: https://sass-lang.com/documentation/at-rules/forward/#controlling-visibility +[builtin]: https://sass-lang.com/documentation/modules/ diff --git a/docs/src/user-guide/diagnostics.md b/docs/src/user-guide/diagnostics.md new file mode 100644 index 00000000..6875095a --- /dev/null +++ b/docs/src/user-guide/diagnostics.md @@ -0,0 +1,10 @@ +# Diagnostics + +This document describes the diagnostics features of Some Sass. + +## Deprecated symbols + +Symbols documented as [`@deprecated`](http://sassdoc.com/annotations/#deprecated) with SassDoc is shown +with a strikethrough. + +![](../images/usage/diagnostics-deprecated.png) diff --git a/docs/src/user-guide/hover.md b/docs/src/user-guide/hover.md new file mode 100644 index 00000000..efd93c82 --- /dev/null +++ b/docs/src/user-guide/hover.md @@ -0,0 +1,40 @@ +# Hover info + +This document describes the hover information given by Some Sass. + +## Symbol information + +When you hover over a symbol Some Sass shows a preview of the declaration and the name of the file where it is declared. Things get more interesting when you add SassDoc though. + +## SassDoc documentation + +If a symbol is documented with [SassDoc], the documentation is shown in the hover information like how you might see JSDoc. This is especially helpful if you have a core set of utility functions and mixins, or if you use a Sass library provided by a third party. + +```scss +/// Calculate a responsive size value relative to a given screen size +/// Will return a CSS rule that corresponds to the given pixel size at +/// the given screen size and scales with changes in screen size +/// @param {Number} $px-size - Size to calculate from, in px without unit +/// @param {Number} $screen-width - Screen width to calculate from, in px without unit, default 1400 +/// @param {Number} $screen-height - Screen height to calculate from, in px without unit, default 900 +/// @return {Number} - Input expressed as a responsive value +@function relative-size($px-size, $screen-width: 1400, $screen-height: 900) { + // ... +} +``` + +![Screenshot showing hover info for a function named relative-size. There's a description of what the function does. There's a list of three parameters of type Number, two of them shown with default values and each with a description.](../images/usage/sassdoc-hover.png) + +## Sass built-ins + +Hover information for Sass built-ins include links to the reference documentation. + +![Screenshot showing hover info for the floor function. The information reads Rounds down to the nearest number and includes a link titled Sass reference.](../images/usage/sass-built-in-hover.png) + +## SassDoc annotations + +Hover information for Sassdoc annotations link to the reference documentation. + +![Screenshot showing hover info for @param in a SassDoc block. @param and a link to SassDoc reference.](../images/usage/sassdoc-annotation-hover.png) + +[SassDoc]: http://sassdoc.com/annotations#description diff --git a/docs/src/user-guide/navigation.md b/docs/src/user-guide/navigation.md new file mode 100644 index 00000000..7a1e0f1b --- /dev/null +++ b/docs/src/user-guide/navigation.md @@ -0,0 +1,35 @@ +# Navigation + +This document describes the navigation features of Some Sass. + +## Go to definition + +To use this feature, either: + +- Hold down `Cmd`/`Ctrl` and click a symbol. +- Right-click a symbol and choose Go to Definition. +- Press `F12` when the cursor is at a symbol. + +[Go to definition reference](https://code.visualstudio.com/docs/editor/editingevolved#_go-to-definition). + +![](../images/usage/go-to-definition.gif) + +## Find references + +To use this feature, either: + +- Right-click a symbol and choose Find all references. +- Press `Shift` + `Alt`/`Opt` + `F12` when the cursor is at a symbol. + +[Find all references reference](https://code.visualstudio.com/docs/getstarted/tips-and-tricks#_find-all-references-view). + +![](../images/usage/find-references.gif) + +## Go to symbol + +To use this feature, open the Go menu and choos either: + +- Go to symbol in Editor +- Go to symbol in Workspace + +[Go to symbol reference](https://code.visualstudio.com/Docs/editor/editingevolved#_go-to-symbol). diff --git a/docs/src/user-guide/refactoring.md b/docs/src/user-guide/refactoring.md new file mode 100644 index 00000000..da6a6c22 --- /dev/null +++ b/docs/src/user-guide/refactoring.md @@ -0,0 +1,19 @@ +# Refactoring + +This document describes the refactoring features of Some Sass. + +## Rename symbol + +With Some Sass installed you can rename a symbol and it is renamed across the whole workspace. + +[Rename symbols reference](https://code.visualstudio.com/docs/editor/refactoring#_rename-symbol) + +![](../images/usage/rename-symbol.gif) + +## Extract + +Some Sass adds code actions to extract a selection to a variable, function or mixin. + +[Extract actions reference](https://code.visualstudio.com/docs/editor/refactoring#_refactoring-actions) + +![](../images/usage/extract.gif) diff --git a/docs/src/user-guide/settings.md b/docs/src/user-guide/settings.md new file mode 100644 index 00000000..341bb981 --- /dev/null +++ b/docs/src/user-guide/settings.md @@ -0,0 +1,111 @@ +# Settings + +This document describes the settings available in Some Sass. + +## Recommended settings + +These are the recommended settings: + +```jsonc +{ + // Recommended if you don't rely on @import + "somesass.suggestOnlyFromUse": true, + + // Optional, if you get suggestions from the current document after namespace.$ (you don't need the $ for narrowing down suggestions) + "editor.wordBasedSuggestions": false, + + // Optional, for Vue, Svelte, Astro: add `scss` to the list of excluded languages for Emmet to avoid suggestions in Vue, Svelte or Astro files. + // VS Code understands that '), [[34, 34]], ); - deepStrictEqual(getSCSSRegions(''), [ - [26, 26], + assert.deepStrictEqual( + getSCSSRegions(''), + [[26, 26]], + ); + assert.deepStrictEqual( + getSCSSRegions(''), + [[26, 26]], + ); + assert.deepStrictEqual(getSCSSRegions(''), [ + [19, 19], ]); - deepStrictEqual(getSCSSRegions(''), [ - [26, 26], + assert.deepStrictEqual(getSCSSRegions(""), [ + [19, 19], ]); - deepStrictEqual(getSCSSRegions(''), [[19, 19]]); - deepStrictEqual(getSCSSRegions(""), [[19, 19]]); - deepStrictEqual(getSCSSRegions(''), [ + assert.deepStrictEqual(getSCSSRegions(''), [ [24, 24], ]); - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions( "", ), [[90, 90]], ); - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions( "\n", ), [[91, 91]], ); - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions( "\n", ), [[91, 92]], ); - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions( "\n", ), [[91, 110]], ); - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions( "\n", ), @@ -101,7 +119,7 @@ describe("Utils/VueSvelte", () => { [143, 162], ], ); - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions( "\n\n", ), @@ -112,19 +130,22 @@ describe("Utils/VueSvelte", () => { ], ); - deepStrictEqual(getSCSSRegions(''), []); - deepStrictEqual(getSCSSRegions(''), []); - deepStrictEqual(getSCSSRegions(''), []); - deepStrictEqual(getSCSSRegions(""), []); - deepStrictEqual(getSCSSRegions(''), []); + assert.deepStrictEqual(getSCSSRegions(''), []); + assert.deepStrictEqual(getSCSSRegions(''), []); + assert.deepStrictEqual( + getSCSSRegions(''), + [], + ); + assert.deepStrictEqual(getSCSSRegions(""), []); + assert.deepStrictEqual(getSCSSRegions(''), []); }); it("getSCSSContent", () => { - strictEqual( + assert.strictEqual( getSCSSContent("sadja|sio|fuioaf", [[5, 10]]), " |sio| ", ); - strictEqual( + assert.strictEqual( getSCSSContent("sadja|sio|fuio^af^", [ [5, 10], [14, 18], @@ -132,7 +153,7 @@ describe("Utils/VueSvelte", () => { " |sio| ^af^", ); - strictEqual( + assert.strictEqual( getSCSSContent( "", ), @@ -147,8 +168,8 @@ describe("Utils/VueSvelte", () => { 1, "", ); - strictEqual( - getSCSSRegionsDocument(exSCSSDocument, Position.create(0, 0)).document, + assert.strictEqual( + getSCSSRegionsDocument(exSCSSDocument, Position.create(0, 0)), exSCSSDocument, ); @@ -165,30 +186,30 @@ describe("Utils/VueSvelte", () => { `, ); - notDeepStrictEqual( - getSCSSRegionsDocument(exVueDocument, Position.create(2, 15)).document, + assert.notDeepEqual( + getSCSSRegionsDocument(exVueDocument, Position.create(2, 15)), exVueDocument, ); - deepStrictEqual( - getSCSSRegionsDocument(exVueDocument, Position.create(2, 15)).document, + assert.deepStrictEqual( + getSCSSRegionsDocument(exVueDocument, Position.create(2, 15)), null, ); - notDeepStrictEqual( - getSCSSRegionsDocument(exVueDocument, Position.create(5, 15)).document, + assert.notDeepEqual( + getSCSSRegionsDocument(exVueDocument, Position.create(5, 15)), exVueDocument, ); - notDeepStrictEqual( - getSCSSRegionsDocument(exVueDocument, Position.create(5, 15)).document, + assert.notDeepEqual( + getSCSSRegionsDocument(exVueDocument, Position.create(5, 15)), null, ); - notDeepStrictEqual( - getSCSSRegionsDocument(exVueDocument, Position.create(6, 9)).document, + assert.notDeepEqual( + getSCSSRegionsDocument(exVueDocument, Position.create(6, 9)), exVueDocument, ); - deepStrictEqual( - getSCSSRegionsDocument(exVueDocument, Position.create(6, 9)).document, + assert.deepStrictEqual( + getSCSSRegionsDocument(exVueDocument, Position.create(6, 9)), null, ); }); diff --git a/packages/language-server/src/utils/embedded.ts b/packages/language-server/src/utils/embedded.ts index 1cae8a97..f9610bc2 100644 --- a/packages/language-server/src/utils/embedded.ts +++ b/packages/language-server/src/utils/embedded.ts @@ -50,14 +50,24 @@ export function getSCSSContent( return newContent; } +/** + * Function that extracts only the SCSS region of a template + * language such as Vue, Svelte or Astro. This is not the correct + * approach for embedded languages, compared to say the HTML language + * server. + * + * @todo Look into how to do this properly with a goal to unship this custom handling. + */ export function getSCSSRegionsDocument( - document: TextDocument, + document: TextDocument | null | undefined = null, position?: Position, -) { +): TextDocument | null { + if (!document) return document; + const offset = position ? document.offsetAt(position) : 0; if (!isFileWhereScssCanBeEmbedded(document.uri)) { - return { document, offset }; + return document; } const text = document.getText(); @@ -70,16 +80,13 @@ export function getSCSSRegionsDocument( const uri = document.uri; const version = document.version; - return { - document: TextDocument.create( - uri, - "scss", - version, - getSCSSContent(text, scssRegions), - ), - offset, - }; + return TextDocument.create( + uri, + "scss", + version, + getSCSSContent(text, scssRegions), + ); } - return { document: null, offset }; + return null; } diff --git a/packages/language-server/src/utils/scss.ts b/packages/language-server/src/utils/scss.ts deleted file mode 100644 index 923dc787..00000000 --- a/packages/language-server/src/utils/scss.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { getDefinition } from "../features/go-definition"; -import { IScssDocument, NodeType, ScssVariable } from "../parser"; -import { getParentNodeByType } from "../parser/ast"; - -export function isReferencingVariable(variable: ScssVariable): boolean { - if (!variable.value) { - return false; - } - return variable.value.startsWith("$") || variable.value.includes(".$"); -} - -export function getBaseValueFrom( - variable: ScssVariable, - scssDocument: IScssDocument, - depth = 0, -): ScssVariable { - if (depth > 10) { - // Really? - return variable; - } - - const node = scssDocument.getNodeAt(variable.offset); - if (!node) { - return variable; - } - - const declaration = getParentNodeByType(node, NodeType.VariableDeclaration); - if (!declaration) { - return variable; - } - - const value = declaration.getValue()?.getText(); - if (!value) { - return variable; - } - - const referenceOffset = - variable.offset + variable.name.length + value.indexOf("$") + 2; - - const referenceNode = scssDocument.getNodeAt(referenceOffset); - if (!referenceNode) { - return variable; - } - - const result = getDefinition(scssDocument.textDocument, referenceOffset); - if (!result) { - return variable; - } - - const [definition, definitionDocument] = result; - if (isReferencingVariable(definition as ScssVariable)) { - return getBaseValueFrom( - definition as ScssVariable, - definitionDocument, - (depth += 1), - ); - } - - return definition as ScssVariable; -} diff --git a/packages/language-server/src/utils/string.ts b/packages/language-server/src/utils/string.ts deleted file mode 100644 index 35b41e4f..00000000 --- a/packages/language-server/src/utils/string.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { IEditorSettings } from "../settings"; - -/** - * Returns word by specified position. - */ -export function getCurrentWord(text: string, offset: number) { - let i = offset - 1; - while (i >= 0 && !' \t\n\r":[()]}/,'.includes(text.charAt(i))) { - i--; - } - - return text.substring(i + 1, offset); -} - -/** - * Returns text before specified position. - */ -export function getTextBeforePosition(text: string, offset: number) { - let i = offset - 1; - while (!"\n\r".includes(text.charAt(i))) { - i--; - } - - return text.substring(i + 1, offset); -} - -/** - * Returns text after specified position. - */ -export function getTextAfterPosition(text: string, offset: number) { - let i = offset + 1; - while (!"\n\r".includes(text.charAt(i))) { - i++; - } - - return text.substring(i + 1, offset); -} - -export const reNewline = /\r\n|\r|\n/; - -export function getLinesFromText(text: string): string[] { - return text.split(reNewline); -} - -const space = " "; -const tab = " "; - -export function indentText(text: string, settings: IEditorSettings): string { - if (settings.insertSpaces) { - const numberOfSpaces: number = - typeof settings.indentSize === "number" - ? settings.indentSize - : typeof settings.tabSize === "number" - ? settings.tabSize - : 2; - return `${space.repeat(numberOfSpaces)}${text}`; - } - - return `${tab}${text}`; -} - -/** Strips the dollar prefix off a variable name */ -export function asDollarlessVariable(variable: string): string { - return variable.replace(/^\$/, ""); -} - -export function stripTrailingComma(string: string): string { - return stripTrailingCharacter(string, ","); -} - -export function stripParentheses(string: string): string { - return string.replace(/[()]/g, ""); -} - -function stripTrailingCharacter(string: string, char: string): string { - return string.endsWith(char) - ? string.slice(0, Math.max(0, string.length - char.length)) - : string; -} - -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * MIT License - * - * Copyright (c) 2015 - present Microsoft Corporation - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - *--------------------------------------------------------------------------------------------*/ -export function getEOL(text: string): string { - for (let i = 0; i < text.length; i++) { - const ch = text.charAt(i); - if (ch === "\r") { - if (i + 1 < text.length && text.charAt(i + 1) === "\n") { - return "\r\n"; - } - return "\r"; - } else if (ch === "\n") { - return "\n"; - } - } - return "\n"; -} diff --git a/packages/language-server/src/workspace-scanner.ts b/packages/language-server/src/workspace-scanner.ts new file mode 100644 index 00000000..293175ec --- /dev/null +++ b/packages/language-server/src/workspace-scanner.ts @@ -0,0 +1,91 @@ +import { + FileSystemProvider, + LanguageService, +} from "@somesass/language-services"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { URI } from "vscode-uri"; +import { getSCSSRegionsDocument } from "./utils/embedded"; + +export default class WorkspaceScanner { + #ls: LanguageService; + #fs: FileSystemProvider; + #settings: { scannerDepth: number; scanImportedFiles: boolean }; + + constructor( + ls: LanguageService, + fs: FileSystemProvider, + settings = { scannerDepth: 30, scanImportedFiles: true }, + ) { + this.#ls = ls; + this.#fs = fs; + this.#settings = settings; + } + + public async scan(files: URI[]): Promise { + // Populate the cache for the new language services + await Promise.all( + files.map((uri) => { + if ( + this.#settings.scanImportedFiles && + (uri.path.includes("/_") || uri.path.includes("\\_")) + ) { + // If we scan imported files (which we do by default), don't include partials in the initial scan. + // This way we can be reasonably sure that we scan whatever index files there are _before_ we scan + // partials which may or may not have been forwarded with a prefix. + return; + } + return this.parse(uri); + }), + ); + } + + private async parse(file: URI, depth = 0) { + const maxDepth = this.#settings.scannerDepth ?? 30; + if (depth > maxDepth || !this.#settings.scanImportedFiles) { + return; + } + + let uri = file; + if (file.scheme === "vscode-test-web") { + // TODO: test-web paths includes /static/extensions/fs which causes issues. + // The URI ends up being vscode-test-web://mount/static/extensions/fs/file.scss when it should only be vscode-test-web://mount/file.scss. + // This should probably be landed as a bugfix somewhere upstream. + uri = URI.parse(file.toString().replace("/static/extensions/fs", "")); + } + + const alreadyParsed = this.#ls.hasCached(uri); + if (alreadyParsed) { + // The same file may be referenced by multiple other files, + // so skip doing the parsing work if it's already been done. + // Changes to the file are handled by the `update` method. + return; + } + + const content = await this.#fs.readFile(uri); + + const document = getSCSSRegionsDocument( + TextDocument.create(uri.toString(), "scss", 1, content), + ); + if (!document) return; + + this.#ls.parseStylesheet(document); + + const links = await this.#ls.findDocumentLinks(document); + for (const link of links) { + if ( + !link.target || + link.target.endsWith(".css") || + link.target.includes("#{") || + link.target.startsWith("sass:") + ) { + continue; + } + + try { + await this.parse(URI.parse(link.target), depth + 1); + } catch { + // do nothing + } + } + } +} diff --git a/packages/language-server/test/.eslintrc.json b/packages/language-server/test/.eslintrc.json deleted file mode 100644 index 4137fafd..00000000 --- a/packages/language-server/test/.eslintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "root": false, - "rules": { - "no-template-curly-in-string": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-non-null-assertion": "off" - } -} diff --git a/packages/language-server/test/features/code-actions/extract.spec.ts b/packages/language-server/test/features/code-actions/extract.spec.ts deleted file mode 100644 index f91ecfe9..00000000 --- a/packages/language-server/test/features/code-actions/extract.spec.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { deepStrictEqual } from "assert"; -import { EOL } from "os"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { Position, Range } from "vscode-languageserver-types"; -import { ExtractProvider } from "../../../src/features/code-actions"; - -describe("Providers/Extract", () => { - it("supports extracting a variable", async () => { - const provider = new ExtractProvider({ - tabSize: 2, - insertSpaces: true, - indentSize: 2, - }); - - const document = TextDocument.create( - "unit.scss", - "scss", - 1, - `--var: black;${EOL}`, - ); - const selection = Range.create( - Position.create(0, 7), - Position.create(0, 12), - ); - - const [variableAction] = await provider.provideCodeActions( - document, - selection, - ); - - deepStrictEqual(variableAction.edit?.documentChanges, [ - { - edits: [ - { - newText: `$_variable: black;${EOL}--var: $_variable`, - range: { - end: { - character: 12, - line: 0, - }, - start: { - character: 0, - line: 0, - }, - }, - }, - ], - textDocument: { - uri: "unit.scss", - version: 1, - }, - }, - ]); - }); - - it("supports extracting a multiline variable", async () => { - const provider = new ExtractProvider({ - tabSize: 2, - insertSpaces: false, - indentSize: 2, - }); - - const document = TextDocument.create( - "unit.scss", - "scss", - 1, - `box-shadow: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), - 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); -`, - ); - const selection = Range.create( - Position.create(0, 12), - Position.create(1, 111), - ); - - const [variableAction] = await provider.provideCodeActions( - document, - selection, - ); - - deepStrictEqual(variableAction.edit?.documentChanges, [ - { - edits: [ - { - newText: `$_variable: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), - 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); -box-shadow: $_variable;`, - range: { - end: { - character: 111, - line: 1, - }, - start: { - character: 0, - line: 0, - }, - }, - }, - ], - textDocument: { - uri: "unit.scss", - version: 1, - }, - }, - ]); - }); - - it("supports extracting a mixin with tab indents", async () => { - const provider = new ExtractProvider({ - tabSize: 2, - insertSpaces: false, - indentSize: 2, - }); - - const document = TextDocument.create( - "unit.scss", - "scss", - 1, - ` -a.cta { - color: var(--cta-text); - text-decoration: none; - - &:visited { - color: var(--cta-text); - } -} -`, - ); - const selection = Range.create( - Position.create(2, 1), - Position.create(7, 2), - ); - - const [, mixinAction] = await provider.provideCodeActions( - document, - selection, - ); - - deepStrictEqual(mixinAction.edit?.documentChanges, [ - { - edits: [ - { - // prettier-ignore - newText: `@mixin _mixin { - color: var(--cta-text); - text-decoration: none; - - &:visited { - color: var(--cta-text); - } - } - @include _mixin;`, - range: { - end: { - character: 2, - line: 7, - }, - start: { - character: 1, - line: 2, - }, - }, - }, - ], - textDocument: { - uri: "unit.scss", - version: 1, - }, - }, - ]); - }); - - it("supports extracting a mixin with space indents", async () => { - const provider = new ExtractProvider({ - tabSize: 2, - insertSpaces: true, - indentSize: 4, - }); - - const document = TextDocument.create( - "unit.scss", - "scss", - 1, - ` -a.cta { - color: var(--cta-text); - text-decoration: none; - - &:visited { - color: var(--cta-text); - } -} -`, - ); - const selection = Range.create( - Position.create(2, 4), - Position.create(7, 5), - ); - - const [, mixinAction] = await provider.provideCodeActions( - document, - selection, - ); - - deepStrictEqual(mixinAction.edit?.documentChanges, [ - { - edits: [ - { - // prettier-ignore - newText: `@mixin _mixin { - color: var(--cta-text); - text-decoration: none; - - &:visited { - color: var(--cta-text); - } - } - @include _mixin;`, - range: { - end: { - character: 5, - line: 7, - }, - start: { - character: 4, - line: 2, - }, - }, - }, - ], - textDocument: { - uri: "unit.scss", - version: 1, - }, - }, - ]); - }); - - it("supports extracting a function with tab indents", async () => { - const provider = new ExtractProvider({ - tabSize: 2, - insertSpaces: false, - indentSize: 2, - }); - - const document = TextDocument.create( - "unit.scss", - "scss", - 1, - `box-shadow: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), - 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); -`, - ); - const selection = Range.create( - Position.create(0, 12), - Position.create(1, 111), - ); - - const [, , functionAction] = await provider.provideCodeActions( - document, - selection, - ); - - deepStrictEqual(functionAction.edit?.documentChanges, [ - { - edits: [ - { - newText: `@function _function() { - @return inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), - 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); -} -box-shadow: _function();`, - range: { - end: { - character: 111, - line: 1, - }, - start: { - character: 0, - line: 0, - }, - }, - }, - ], - textDocument: { - uri: "unit.scss", - version: 1, - }, - }, - ]); - }); - - it("supports extracting a function with space indents", async () => { - const provider = new ExtractProvider({ - tabSize: 2, - insertSpaces: true, - indentSize: 2, - }); - - const document = TextDocument.create( - "unit.scss", - "scss", - 1, - `box-shadow: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), - 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); -`, - ); - const selection = Range.create( - Position.create(0, 12), - Position.create(1, 112), - ); - - const [, , functionAction] = await provider.provideCodeActions( - document, - selection, - ); - - deepStrictEqual(functionAction.edit?.documentChanges, [ - { - edits: [ - { - newText: `@function _function() { - @return inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), - 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); -} -box-shadow: _function();`, - range: { - end: { - character: 112, - line: 1, - }, - start: { - character: 0, - line: 0, - }, - }, - }, - ], - textDocument: { - uri: "unit.scss", - version: 1, - }, - }, - ]); - }); -}); diff --git a/packages/language-server/test/features/completion-visibility.spec.ts b/packages/language-server/test/features/completion-visibility.spec.ts deleted file mode 100644 index df8bcfd8..00000000 --- a/packages/language-server/test/features/completion-visibility.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as assert from "assert"; -import { changeConfiguration, useContext } from "../../src/context-provider"; -import { doCompletion } from "../../src/features/completion"; -import { NodeFileSystem } from "../../src/node-file-system"; -import { IScssDocument } from "../../src/parser"; -import ScannerService from "../../src/scanner"; -import { getUri } from "../fixture-helper"; -import * as helpers from "../helpers"; - -describe("Providers/Completion", () => { - beforeEach(async () => { - helpers.createTestContext(new NodeFileSystem()); - - const settings = helpers.makeSettings({ - suggestFromUseOnly: true, - }); - changeConfiguration(settings); - }); - - describe("Hide", () => { - it("supports multi-level hiding", async () => { - const workspaceUri = getUri("completion/multi-level-hide/"); - const docUri = getUri("completion/multi-level-hide/styles.scss"); - const scanner = new ScannerService(); - await scanner.scan([docUri], workspaceUri); - const { storage } = useContext(); - const stylesDoc = storage.get(docUri) as IScssDocument; - - const completions = await doCompletion( - stylesDoc, - stylesDoc.getText().indexOf("|"), - ); - - // $color-black and $color-white are hidden at different points - - assert.equal( - completions.items.length, - 1, - "Expected only one suggestion from the multi-level-hide fixture", - ); - assert.equal(completions.items[0].label, "$color-grey"); - }); - - it("doesn't hide symbol with same name in different part of dependency graph", async () => { - const workspaceUri = getUri("completion/same-symbol-name-hide/"); - const docUri = getUri("completion/same-symbol-name-hide/styles.scss"); - const scanner = new ScannerService(); - await scanner.scan([docUri], workspaceUri); - const { storage } = useContext(); - const stylesDoc = storage.get(docUri) as IScssDocument; - - const completions = await doCompletion( - stylesDoc, - stylesDoc.getText().indexOf("|"), - ); - - // $color-white is hidden in branch-a, but not in branch-b - assert.equal( - completions.items.length, - 1, - "Expected a suggestion from the same-symbol-name-hide fixture", - ); - assert.equal(completions.items[0].label, "$color-white"); - }); - }); - - describe("Show", () => { - it("doesn't show symbol with same name in different part of dependency graph", async () => { - const workspaceUri = getUri("completion/same-symbol-name-show/"); - const docUri = getUri("completion/same-symbol-name-show/styles.scss"); - const scanner = new ScannerService(); - await scanner.scan([docUri], workspaceUri); - const { storage } = useContext(); - const stylesDoc = storage.get(docUri) as IScssDocument; - - const completions = await doCompletion( - stylesDoc, - stylesDoc.getText().indexOf("|"), - ); - - // One branch only shows $color-black, but the other has three symbols including another $color-black - assert.equal( - completions.items.length, - 4, - "Expected four suggestions from the same-symbol-name-show fixture", - ); - }); - }); -}); diff --git a/packages/language-server/test/features/completion.spec.ts b/packages/language-server/test/features/completion.spec.ts deleted file mode 100644 index a7e13b85..00000000 --- a/packages/language-server/test/features/completion.spec.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { strictEqual, ok } from "assert"; -import { - CompletionItem, - CompletionItemKind, - SymbolKind, -} from "vscode-languageserver"; -import type { CompletionList } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { changeConfiguration, useContext } from "../../src/context-provider"; -import { doCompletion } from "../../src/features/completion"; -import { parseStringLiteralChoices } from "../../src/features/completion/completion-utils"; -import { rePartialUse } from "../../src/features/completion/import-completion"; -import { sassBuiltInModules } from "../../src/features/sass-built-in-modules"; -import { sassDocAnnotations } from "../../src/features/sassdoc-annotations"; -import { INode, ScssDocument } from "../../src/parser"; -import { getLanguageService } from "../../src/parser/language-service"; -import { ISettings } from "../../src/settings"; -import * as helpers from "../helpers"; - -async function getCompletionList( - lines: string[], - options?: Partial, -): Promise { - const text = lines.join("\n"); - - const settings = helpers.makeSettings(options); - changeConfiguration(settings); - - const document = await helpers.makeDocument(text); - const offset = text.indexOf("|"); - - return doCompletion(document, offset); -} - -describe("Providers/Completion", () => { - beforeEach(() => { - helpers.createTestContext(); - - const document = TextDocument.create("./one.scss", "scss", 1, ""); - - const ls = getLanguageService(); - const ast = ls.parseStylesheet(document) as INode; - - const { fs, storage } = useContext(); - - storage.set( - "one.scss", - new ScssDocument( - fs, - document, - { - variables: new Map([ - [ - "$one", - { - name: "$one", - kind: SymbolKind.Variable, - value: "1", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "$two", - { - name: "$two", - kind: SymbolKind.Variable, - value: null, - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "$hex", - { - name: "$hex", - kind: SymbolKind.Variable, - value: "#fff", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "$rgb", - { - name: "$rgb", - kind: SymbolKind.Variable, - value: "rgb(0,0,0)", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "$word", - { - name: "$word", - kind: SymbolKind.Variable, - value: "red", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - mixins: new Map([ - [ - "test", - { - name: "test", - kind: SymbolKind.Method, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - functions: new Map([ - [ - "make", - { - name: "make", - kind: SymbolKind.Function, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - imports: new Map(), - uses: new Map(), - forwards: new Map(), - placeholders: new Map(), - placeholderUsages: new Map(), - }, - ast, - ), - ); - }); - - describe("Basic", () => { - it("Variables", async () => { - const actual = await getCompletionList(["$|"]); - - strictEqual(actual.items.length, 5); - }); - - it("Mixins", async () => { - const actual = await getCompletionList(["@include |"]); - - strictEqual(actual.items.length, 1); - }); - }); - - describe("Context", () => { - it("Empty property value", async () => { - const actual = await getCompletionList([".a { content: | }"]); - - strictEqual(actual.items.length, 5); - }); - - it("Non-empty property value without suggestions", async () => { - const actual = await getCompletionList([ - ".a { background: url(../images/one|.png); }", - ]); - - strictEqual(actual.items.length, 0); - }); - - it("Non-empty property value with Variables", async () => { - const actual = await getCompletionList([ - ".a { background: url(../images/#{$one|}/one.png); }", - ]); - - strictEqual(actual.items.length, 5); - }); - - it("Discard suggestions inside quotes", async () => { - const actual = await getCompletionList([ - ".a {", - ' background: url("../images/#{$one}/$one|.png");', - "}", - ]); - - strictEqual(actual.items.length, 0); - }); - - it("Custom value for `suggestFunctionsInStringContextAfterSymbols` option", async () => { - const actual = await getCompletionList( - [".a { background: url(../images/m|"], - { - suggestFunctionsInStringContextAfterSymbols: "/", - }, - ); - - strictEqual(actual.items.length, 1); - }); - - it("Discard suggestions inside single-line comments", async () => { - const actual = await getCompletionList(["// $|"]); - - strictEqual(actual.items.length, 0); - }); - - it("Discard suggestions inside block comments", async () => { - const actual = await getCompletionList(["/* $| */"]); - - strictEqual(actual.items.length, 0); - }); - - it("Identify color variables", async () => { - const actual = await getCompletionList(["$|"]); - - strictEqual(actual.items[0]?.kind, CompletionItemKind.Variable); - strictEqual(actual.items[1]?.kind, CompletionItemKind.Variable); - strictEqual(actual.items[2]?.kind, CompletionItemKind.Color); - strictEqual(actual.items[3]?.kind, CompletionItemKind.Color); - strictEqual(actual.items[4]?.kind, CompletionItemKind.Color); - }); - }); - - describe("Import", () => { - it("Suggests built-in Sass modules", async () => { - const expectedCompletionLabels = Object.keys(sassBuiltInModules); - - const actual = await getCompletionList(['@use "|']); - - ok( - expectedCompletionLabels.every((expectedLabel) => { - return actual.items.some((item) => item.label === expectedLabel); - }), - "Expected to find all Sass built-in modules, but some or all are missing", - ); - }); - - it("rePartialUse matches expected things", () => { - ok( - !rePartialUse.test("@use "), - "should not match unless there's an opening quote", - ); - ok( - rePartialUse.test('@use "'), - "should match an empty opening @use with double quote", - ); - ok( - rePartialUse.test("@use '"), - "should match an empty opening @use with single quote", - ); - ok(rePartialUse.test("@use '~foo"), "should match with tilde"); - ok( - rePartialUse.test("@use './foo"), - "should match with relative import in same directory", - ); - ok( - rePartialUse.test("@use '../foo"), - "should match with relative import in parent", - ); - ok( - rePartialUse.test("@use '../../foo"), - "should match with relative import in grandparent", - ); - ok( - rePartialUse.test("@use 'foo"), - "should match without special character prefix", - ); - ok(rePartialUse.test("@use 'foo'"), "should match with closing quote"); - ok( - rePartialUse.test("@use 'foo';"), - "should match with closing semicolon", - ); - - const actual = rePartialUse.exec("@use '../../foo"); - ok(actual, "expected match to return a result"); - strictEqual( - actual?.[1], - "../../foo", - "expected match to include the url", - ); - }); - }); - - describe("Built-in", () => { - it("Suggests items from built-in Sass modules", async () => { - const actual = await getCompletionList([ - '@use "sass:color" as magic;', - ".a { color: magic.ch|; }", - ]); - - ok( - actual.items.some((item) => item.label === "change"), - "Expected to find a change-function in the Sass built-in color module, but it's missing", - ); - }); - }); - - describe("Utils", () => { - it("parseStringLiteralChoices returns an array of string literals from a docstring", () => { - let result = parseStringLiteralChoices('"foo"'); - strictEqual(result.join(", "), '"foo"'); - - result = parseStringLiteralChoices('"foo" | "bar"'); - strictEqual(result.join(", "), '"foo", "bar"'); - - result = parseStringLiteralChoices("String | Number"); - strictEqual(result.join(", "), ""); - - result = parseStringLiteralChoices('"String" | "Number"'); - strictEqual(result.join(", "), '"String", "Number"'); - }); - }); - - describe("SassDoc", () => { - it("Offers completions for SassDoc annotations on variable", async () => { - const expectedCompletions = sassDocAnnotations.map((a) => a.annotation); - - const actual = await getCompletionList(["///|", "$doc-variable: 1px;"]); - - ok( - expectedCompletions.every((annotation) => - actual.items.find( - (item: CompletionItem) => item.label === annotation, - ), - ), - "One or more expected SassDoc annotations were not present.", - ); - }); - }); -}); diff --git a/packages/language-server/test/features/diagnostics.spec.ts b/packages/language-server/test/features/diagnostics.spec.ts deleted file mode 100644 index 05030fbe..00000000 --- a/packages/language-server/test/features/diagnostics.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { strictEqual, deepStrictEqual, ok } from "assert"; -import { DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver-types"; -import { EXTENSION_NAME } from "../../src/constants"; -import { doDiagnostics } from "../../src/features/diagnostics/diagnostics"; -import * as helpers from "../helpers"; - -describe("Providers/Diagnostics", () => { - beforeEach(() => helpers.createTestContext()); - - it("doDiagnostics - Variables", async () => { - const document = await helpers.makeDocument([ - "/// @deprecated Use something else", - "$a: 1;", - ".a { content: $a; }", - ]); - - const actual = await doDiagnostics(document); - - deepStrictEqual(actual, [ - { - message: "Use something else", - range: { - start: { line: 2, character: 14 }, - end: { line: 2, character: 16 }, - }, - source: EXTENSION_NAME, - tags: [DiagnosticTag.Deprecated], - severity: DiagnosticSeverity.Hint, - }, - ]); - }); - - it("doDiagnostics - Functions", async () => { - const document = await helpers.makeDocument([ - "/// @deprecated Use something else", - "@function old-function() {", - " @return 1;", - "}", - ".a { content: old-function(); }", - ]); - - const actual = await doDiagnostics(document); - - deepStrictEqual(actual, [ - { - message: "Use something else", - range: { - start: { line: 4, character: 14 }, - end: { line: 4, character: 28 }, - }, - source: EXTENSION_NAME, - tags: [DiagnosticTag.Deprecated], - severity: DiagnosticSeverity.Hint, - }, - ]); - }); - - it("doDiagnostics - Mixins", async () => { - const document = await helpers.makeDocument([ - "/// @deprecated Use something else", - "@mixin old-mixin {", - " content: 'mixin';", - "}", - ".a { @include old-mixin(); }", - ]); - - const actual = await doDiagnostics(document); - - deepStrictEqual(actual, [ - { - message: "Use something else", - range: { - start: { line: 4, character: 5 }, - end: { line: 4, character: 25 }, - }, - source: EXTENSION_NAME, - tags: [DiagnosticTag.Deprecated], - severity: DiagnosticSeverity.Hint, - }, - ]); - }); - - it("doDiagnostics - all of the above", async () => { - const document = await helpers.makeDocument([ - "/// @deprecated Use something else", - "$a: 1;", - ".a { content: $a; }", - "", - "/// @deprecated Use something else", - "@function old-function() {", - " @return 1;", - "}", - ".a { content: old-function(); }", - "", - "/// @deprecated Use something else", - "@mixin old-mixin {", - " content: 'mixin';", - "}", - ".a { @include old-mixin(); }", - ]); - - const actual = await doDiagnostics(document); - - strictEqual(actual.length, 3); - - ok( - actual.every((d) => Boolean(d.message)), - "Every diagnostic must have a message", - ); - }); - - it("doDiagnostics - support annotation without description", async () => { - const document = await helpers.makeDocument([ - "/// @deprecated", - "$a: 1;", - ".a { content: $a; }", - "", - "/// @deprecated", - "@function old-function() {", - " @return 1;", - "}", - ".a { content: old-function(); }", - "", - "/// @deprecated", - "@mixin old-mixin {", - " content: 'mixin';", - "}", - ".a { @include old-mixin(); }", - ]); - - const actual = await doDiagnostics(document); - - strictEqual(actual.length, 3); - - // Make sure we set a default message for the deprecated tag - ok( - actual.every((d) => Boolean(d.message)), - "Every diagnostic must have a message", - ); - }); - - it("doDiagnostics - support namespaces with prefix", async () => { - await helpers.makeDocument(["/// @deprecated", "$old-a: 1;"], { - uri: "variables.scss", - }); - await helpers.makeDocument( - ["/// @deprecated", "@function old-function() {", " @return 1;", "}"], - - { uri: "functions.scss" }, - ); - await helpers.makeDocument( - ["/// @deprecated", "@mixin old-mixin {", " content: 'mixin';", "}"], - - { uri: "mixins.scss" }, - ); - await helpers.makeDocument( - [ - "@forward './functions' as fun-*;", - "@forward './mixins' as mix-* hide secret, other-secret;", - "@forward './variables' hide $secret;", - ], - - { uri: "namespace.scss" }, - ); - - const document = await helpers.makeDocument([ - "@use 'namespace' as ns;", - ".foo {", - " color: ns.$old-a;", - " line-height: ns.fun-old-function();", - " @include ns.mix-old-mixin;", - "}", - ]); - - const actual = await doDiagnostics(document); - - // For some reason we get duplicate diagnostics for mixins. - // Haven't been able to track down why getVariableFunctionMixinReferences produces two of the same node. - // It's probably fine... - strictEqual(actual.length, 4); - - // Make sure we set a default message for the deprecated tag - ok( - actual.every((d) => Boolean(d.message)), - "Every diagnostic must have a message", - ); - }); - - it("doDiagnostics - Placeholders", async () => { - const document = await helpers.makeDocument([ - "/// @deprecated Use something else", - "%oldPlaceholder {", - " content: 'placeholder';", - "}", - ".a { @extend %oldPlaceholder; }", - ]); - - const actual = await doDiagnostics(document); - - deepStrictEqual(actual, [ - { - message: "Use something else", - range: { - start: { line: 4, character: 13 }, - end: { line: 4, character: 28 }, - }, - source: EXTENSION_NAME, - tags: [DiagnosticTag.Deprecated], - severity: DiagnosticSeverity.Hint, - }, - ]); - }); -}); diff --git a/packages/language-server/test/features/go-definition.spec.ts b/packages/language-server/test/features/go-definition.spec.ts deleted file mode 100644 index bac03e55..00000000 --- a/packages/language-server/test/features/go-definition.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { strictEqual, deepStrictEqual, ok } from "assert"; -import { SymbolKind } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../src/context-provider"; -import { goDefinition } from "../../src/features/go-definition/go-definition"; -import { INode, ScssDocument } from "../../src/parser"; -import { getLanguageService } from "../../src/parser/language-service"; -import * as helpers from "../helpers"; - -describe("Providers/GoDefinition", () => { - beforeEach(() => { - helpers.createTestContext(); - - const document = TextDocument.create("./one.scss", "scss", 1, ""); - const ls = getLanguageService(); - const ast = ls.parseStylesheet(document) as INode; - - const { fs, storage } = useContext(); - - storage.set( - "one.scss", - new ScssDocument( - fs, - TextDocument.create("./one.scss", "scss", 1, ""), - { - variables: new Map([ - [ - "$a", - { - name: "$a", - kind: SymbolKind.Variable, - value: "1", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - mixins: new Map([ - [ - "mixin", - { - name: "mixin", - kind: SymbolKind.Method, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - functions: new Map([ - [ - "make", - { - name: "make", - kind: SymbolKind.Function, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - imports: new Map(), - uses: new Map(), - forwards: new Map(), - placeholders: new Map(), - placeholderUsages: new Map(), - }, - ast, - ), - ); - }); - - it("doGoDefinition - Variables", async () => { - const document = await helpers.makeDocument(".a { content: $a; }"); - - const actual = goDefinition(document, 15); - - ok(actual); - strictEqual(actual?.uri, "./one.scss"); - deepStrictEqual(actual?.range, { - start: { line: 1, character: 1 }, - end: { line: 1, character: 3 }, - }); - }); - - it("doGoDefinition - Variable definition", async () => { - const document = await helpers.makeDocument("$a: 1;"); - - const actual = goDefinition(document, 2); - - strictEqual(actual, null); - }); - - it("doGoDefinition - Mixins", async () => { - const document = await helpers.makeDocument(".a { @include mixin(); }"); - - const actual = goDefinition(document, 16); - - ok(actual); - strictEqual(actual?.uri, "./one.scss"); - deepStrictEqual(actual?.range, { - start: { line: 1, character: 1 }, - end: { line: 1, character: 6 }, - }); - }); - - it("doGoDefinition - Mixin definition", async () => { - const document = await helpers.makeDocument("@mixin mixin($a) {}"); - - const actual = goDefinition(document, 8); - - strictEqual(actual, null); - }); - - it("doGoDefinition - Mixin Arguments", async () => { - const document = await helpers.makeDocument("@mixin mixin($a) {}"); - - const actual = goDefinition(document, 10); - - strictEqual(actual, null); - }); - - it("doGoDefinition - Functions", async () => { - const document = await helpers.makeDocument(".a { content: make(1); }"); - - const actual = goDefinition(document, 16); - - ok(actual); - strictEqual(actual?.uri, "./one.scss"); - deepStrictEqual(actual?.range, { - start: { line: 1, character: 1 }, - end: { line: 1, character: 5 }, - }); - }); - - it("doGoDefinition - Function definition", async () => { - const document = await helpers.makeDocument("@function make($a) {}"); - - const actual = goDefinition(document, 8); - - strictEqual(actual, null); - }); - - it("doGoDefinition - Function Arguments", async () => { - const document = await helpers.makeDocument("@function make($a) {}"); - - const actual = goDefinition(document, 13); - - strictEqual(actual, null); - }); -}); diff --git a/packages/language-server/test/features/hover.spec.ts b/packages/language-server/test/features/hover.spec.ts deleted file mode 100644 index ca606e62..00000000 --- a/packages/language-server/test/features/hover.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { deepStrictEqual } from "assert"; -import { MarkupKind, SymbolKind } from "vscode-languageserver"; -import type { Hover } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../src/context-provider"; -import { doHover } from "../../src/features/hover/hover"; -import { INode, ScssDocument } from "../../src/parser"; -import { getLanguageService } from "../../src/parser/language-service"; -import * as helpers from "../helpers"; - -async function getHover(lines: string[]): Promise { - let text = lines.join("\n"); - const offset = text.indexOf("|"); - text = text.replace("|", ""); - - const document = await helpers.makeDocument(text); - - return doHover(document, offset); -} - -describe("Providers/Hover", () => { - beforeEach(() => { - helpers.createTestContext(); - - const document = TextDocument.create("./one.scss", "scss", 1, ""); - const ls = getLanguageService(); - const ast = ls.parseStylesheet(document) as INode; - - const { fs, storage } = useContext(); - - storage.set( - "file.scss", - new ScssDocument( - fs, - TextDocument.create("./file.scss", "scss", 1, ""), - { - variables: new Map([ - [ - "$variable", - { - name: "$variable", - kind: SymbolKind.Variable, - value: "2", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - mixins: new Map([ - [ - "mixin", - { - name: "mixin", - kind: SymbolKind.Method, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - functions: new Map([ - [ - "func", - { - name: "func", - kind: SymbolKind.Function, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - imports: new Map(), - uses: new Map(), - forwards: new Map(), - placeholders: new Map(), - placeholderUsages: new Map(), - }, - ast, - ), - ); - }); - - it("should suggest local symbols", async () => { - const actual = await getHover(["$one: 1;", ".a { content: $one|; }"]); - - deepStrictEqual(actual?.contents, { - kind: MarkupKind.Markdown, - value: [ - "```scss", - "$one: 1;", - "```", - "____", - "Variable declared in index.scss", - ].join("\n"), - }); - }); - - it("should suggest global variables", async () => { - const actual = await getHover([".a { content: $variable|; }"]); - - deepStrictEqual(actual?.contents, { - kind: MarkupKind.Markdown, - value: [ - "```scss", - "$variable: 2;", - "```", - "____", - "Variable declared in file.scss", - ].join("\n"), - }); - }); - - it("should suggest global mixins", async () => { - const actual = await getHover([".a { @include mixin| }"]); - - deepStrictEqual(actual?.contents, { - kind: MarkupKind.Markdown, - value: [ - "```scss", - "@mixin mixin()", - "```", - "____", - "Mixin declared in file.scss", - ].join("\n"), - }); - }); - - it("should suggest global functions", async () => { - const actual = await getHover([".a {", " width: func|();", "}"]); - - deepStrictEqual(actual?.contents, { - kind: MarkupKind.Markdown, - value: [ - "```scss", - "@function func()", - "```", - "____", - "Function declared in file.scss", - ].join("\n"), - }); - }); -}); diff --git a/packages/language-server/test/features/references.spec.ts b/packages/language-server/test/features/references.spec.ts deleted file mode 100644 index 80e89e10..00000000 --- a/packages/language-server/test/features/references.spec.ts +++ /dev/null @@ -1,999 +0,0 @@ -import { strictEqual, deepStrictEqual, ok } from "assert"; -import { provideReferences } from "../../src/features/references"; -import * as helpers from "../helpers"; - -describe("Providers/References", () => { - beforeEach(() => { - helpers.createTestContext(); - }); - - it("provideReferences - Variables", async () => { - await helpers.makeDocument('$day: "monday";', { - uri: "ki.scss", - }); - - const firstUsage = await helpers.makeDocument( - ['@use "ki";', "", ".a::after {", " content: ki.$day;", "}"], - { - uri: "helen.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "ki";', - "", - ".a::before {", - " // Here it comes!", - " content: ki.$day;", - "}", - ], - - { - uri: "gato.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 38, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a variable"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to $day: two usage and one declaration", - ); - - const [ki, helen, gato] = actual.references; - - ok(ki?.location.uri.endsWith("ki.scss")); - deepStrictEqual(ki?.location.range, { - start: { - line: 0, - character: 0, - }, - end: { - line: 0, - character: 4, - }, - }); - - ok(helen?.location.uri.endsWith("helen.scss")); - deepStrictEqual(helen?.location.range, { - start: { - line: 3, - character: 13, - }, - end: { - line: 3, - character: 17, - }, - }); - - ok(gato?.location.uri.endsWith("gato.scss")); - deepStrictEqual(gato?.location.range, { - start: { - line: 4, - character: 13, - }, - end: { - line: 4, - character: 17, - }, - }); - }); - - it("provideReferences - @forward variable with prefix", async () => { - await helpers.makeDocument('$day: "monday";', { - uri: "ki.scss", - }); - - await helpers.makeDocument('@forward "ki" as ki-*;', { - uri: "dev.scss", - }); - - const firstUsage = await helpers.makeDocument( - ['@use "dev";', "", ".a::after {", " content: dev.$ki-day;", "}"], - - { - uri: "coast.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "ki";', - "", - ".a::before {", - " // Here it comes!", - " content: ki.$day;", - "}", - ], - - { - uri: "winter.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 42, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a prefixed variable"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to $day: one prefixed usage and one not, plus the declaration", - ); - - const [ki, coast, winter] = actual.references; - - ok(ki?.location.uri.endsWith("ki.scss")); - deepStrictEqual(ki?.location.range, { - start: { - line: 0, - character: 0, - }, - end: { - line: 0, - character: 4, - }, - }); - - ok(coast?.location.uri.endsWith("coast.scss")); - deepStrictEqual(coast?.location.range, { - start: { - line: 3, - character: 14, - }, - end: { - line: 3, - character: 21, - }, - }); - - ok(winter?.location.uri.endsWith("winter.scss")); - deepStrictEqual(winter?.location.range, { - start: { - line: 4, - character: 13, - }, - end: { - line: 4, - character: 17, - }, - }); - }); - - it("provideReferences - @forward visibility for variable", async () => { - await helpers.makeDocument(["$secret: 1;"], { - uri: "var.scss", - }); - - const forward = await helpers.makeDocument( - '@forward "var" as var-* hide $secret;', - - { - uri: "dev.scss", - }, - ); - - const actual = await provideReferences(forward, 33, { - includeDeclaration: true, - }); - - ok( - actual, - "provideReferences returned null for a variable referenced in an @forward hide", - ); - strictEqual( - actual?.references.length, - 2, - "Expected two references to `secret`: one declaration and one as part of a @forward statement (in hide).", - ); - - const [variable, dev] = actual.references; - - ok(variable?.location.uri.endsWith("var.scss")); - deepStrictEqual(variable?.location.range, { - start: { - line: 0, - character: 0, - }, - end: { - line: 0, - character: 7, - }, - }); - - ok(dev?.location.uri.endsWith("dev.scss")); - deepStrictEqual(dev?.location.range, { - start: { - line: 0, - character: 29, - }, - end: { - line: 0, - character: 36, - }, - }); - }); - - it("provideReferences - Functions", async () => { - await helpers.makeDocument( - "@function hello() { @return 1; }", - - { - uri: "func.scss", - }, - ); - - const firstUsage = await helpers.makeDocument( - ['@use "func";', "", ".a {", " line-height: func.hello();", "}"], - - { - uri: "one.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "func";', - "", - ".a {", - " // Here it comes!", - " line-height: func.hello();", - "}", - ], - - { - uri: "two.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 42, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a function"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to hello: two usages and one declaration", - ); - - const [func, one, two] = actual.references; - - ok(func?.location.uri.endsWith("func.scss")); - deepStrictEqual(func?.location.range, { - start: { - line: 0, - character: 10, - }, - end: { - line: 0, - character: 15, - }, - }); - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 3, - character: 19, - }, - end: { - line: 3, - character: 24, - }, - }); - - ok(two?.location.uri.endsWith("two.scss")); - deepStrictEqual(two?.location.range, { - start: { - line: 4, - character: 19, - }, - end: { - line: 4, - character: 24, - }, - }); - }); - - it("provideReferences - @forward function with prefix", async () => { - await helpers.makeDocument( - "@function hello() { @return 1; }", - - { - uri: "func.scss", - }, - ); - - await helpers.makeDocument('@forward "func" as fun-*;', { - uri: "dev.scss", - }); - - const firstUsage = await helpers.makeDocument( - ['@use "dev";', "", ".a {", " line-height: dev.fun-hello();", "}"], - - { - uri: "one.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "func";', - "", - ".a {", - " // Here it comes!", - " line-height: func.hello();", - "}", - ], - - { - uri: "two.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 40, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a prefixed function"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to hello: one prefixed usage and one not, plus the declaration", - ); - - const [func, one, two] = actual.references; - - ok(func?.location.uri.endsWith("func.scss")); - deepStrictEqual(func?.location.range, { - start: { - line: 0, - character: 10, - }, - end: { - line: 0, - character: 15, - }, - }); - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 3, - character: 18, - }, - end: { - line: 3, - character: 27, - }, - }); - - ok(two?.location.uri.endsWith("two.scss")); - deepStrictEqual(two?.location.range, { - start: { - line: 4, - character: 19, - }, - end: { - line: 4, - character: 24, - }, - }); - }); - - it("provideReferences - @forward visibility with function", async () => { - await helpers.makeDocument( - "@function secret() { @return 1; }", - - { - uri: "func.scss", - }, - ); - - const forward = await helpers.makeDocument( - '@forward "func" as fun-* hide secret;', - - { - uri: "dev.scss", - }, - ); - - const actual = await provideReferences(forward, 33, { - includeDeclaration: true, - }); - - ok( - actual, - "provideReferences returned null for a function referenced in an @forward hide", - ); - strictEqual( - actual?.references.length, - 2, - "Expected two references to `secret`: one declaration and one as part of a @forward statement (in hide).", - ); - - const [func, dev] = actual.references; - - ok(func?.location.uri.endsWith("func.scss")); - deepStrictEqual(func?.location.range, { - start: { - line: 0, - character: 10, - }, - end: { - line: 0, - character: 16, - }, - }); - - ok(dev?.location.uri.endsWith("dev.scss")); - deepStrictEqual(dev?.location.range, { - start: { - line: 0, - character: 30, - }, - end: { - line: 0, - character: 36, - }, - }); - }); - - it("provideReferences - Mixins", async () => { - await helpers.makeDocument( - ["@mixin hello() {", " line-height: 1;", "}"], - - { - uri: "mix.scss", - }, - ); - - const firstUsage = await helpers.makeDocument( - ['@use "mix";', "", ".a {", " @include mix.hello();", "}"], - - { - uri: "one.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "mix";', - "", - ".a {", - " // Here it comes!", - " @include mix.hello;", - "}", - ], - - { - uri: "two.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 33, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a mixin"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to hello: two usages and one declaration", - ); - - const [mix, one, two] = actual.references; - - ok(mix?.location.uri.endsWith("mix.scss")); - deepStrictEqual(mix?.location.range, { - start: { - line: 0, - character: 7, - }, - end: { - line: 0, - character: 12, - }, - }); - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 3, - character: 14, - }, - end: { - line: 3, - character: 19, - }, - }); - - ok(two?.location.uri.endsWith("two.scss")); - deepStrictEqual(two?.location.range, { - start: { - line: 4, - character: 14, - }, - end: { - line: 4, - character: 19, - }, - }); - }); - - it("provideReferences - @forward mixin with prefix", async () => { - await helpers.makeDocument( - ["@mixin hello() {", " line-height: 1;", "}"], - - { - uri: "mix.scss", - }, - ); - - await helpers.makeDocument('@forward "mix" as mix-*;', { - uri: "dev.scss", - }); - - const firstUsage = await helpers.makeDocument( - ['@use "dev";', "", ".a {", " @include dev.mix-hello();", "}"], - - { - uri: "one.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "mix";', - "", - ".a {", - " // Here it comes!", - " @include mix.hello();", - "}", - ], - - { - uri: "two.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 33, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a mixin"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to hello: one prefixed usage and one not, plus the declaration", - ); - - const [mix, one, two] = actual.references; - - ok(mix?.location.uri.endsWith("mix.scss")); - deepStrictEqual(mix?.location.range, { - start: { - line: 0, - character: 7, - }, - end: { - line: 0, - character: 12, - }, - }); - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 3, - character: 14, - }, - end: { - line: 3, - character: 23, - }, - }); - - ok(two?.location.uri.endsWith("two.scss")); - deepStrictEqual(two?.location.range, { - start: { - line: 4, - character: 14, - }, - end: { - line: 4, - character: 19, - }, - }); - }); - - it("provideReferences - @forward visibility for mixin", async () => { - await helpers.makeDocument( - ["@mixin secret() {", " line-height: 1;", "}"], - - { - uri: "mix.scss", - }, - ); - - const forward = await helpers.makeDocument( - '@forward "mix" as mix-* hide secret;', - - { - uri: "dev.scss", - }, - ); - - const actual = await provideReferences(forward, 33, { - includeDeclaration: true, - }); - - ok( - actual, - "provideReferences returned null for a mixin referenced in an @forward hide", - ); - strictEqual( - actual?.references.length, - 2, - "Expected two references to `secret`: one declaration and one as part of a @forward statement (in hide).", - ); - - const [mix, dev] = actual.references; - - ok(mix?.location.uri.endsWith("mix.scss")); - deepStrictEqual(mix?.location.range, { - start: { - line: 0, - character: 7, - }, - end: { - line: 0, - character: 13, - }, - }); - - ok(dev?.location.uri.endsWith("dev.scss")); - deepStrictEqual(dev?.location.range, { - start: { - line: 0, - character: 29, - }, - end: { - line: 0, - character: 35, - }, - }); - }); - - it("providesReference - @forward function parameter with prefix", async () => { - await helpers.makeDocument( - [ - "@function hello($var) { @return $var; }", - '$name: "there";', - '$reply: "general";', - ], - - { - uri: "fun.scss", - }, - ); - - await helpers.makeDocument('@forward "fun" as fun-*;', { - uri: "dev.scss", - }); - - const usage = await helpers.makeDocument( - [ - '@use "dev";', - "$_b: 1;", - ".a {", - " // Here it comes!", - " content: dev.fun-hello(dev.$fun-name, $_b);", - "}", - ], - - { - uri: "one.scss", - }, - ); - - const name = await provideReferences(usage, 73, { - includeDeclaration: true, - }); - ok( - name, - "provideReferences returned null for a prefixed variable as a function parameter", - ); - strictEqual( - name?.references.length, - 2, - "Expected two references to $fun-name", - ); - - const [, one] = name.references; - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 4, - character: 28, - }, - end: { - line: 4, - character: 37, - }, - }); - }); - - it("providesReference - @forward in map with prefix", async () => { - await helpers.makeDocument( - ["@function hello() { @return 1; }", '$day: "monday";'], - - { - uri: "fun.scss", - }, - ); - - await helpers.makeDocument('@forward "fun" as fun-*;', { - uri: "dev.scss", - }); - - const usage = await helpers.makeDocument( - [ - '@use "dev";', - "", - "$map: (", - ' "gloomy": dev.$fun-day,', - ' "goodbye": dev.fun-hello(),', - ");", - ], - - { - uri: "one.scss", - }, - ); - - const funDay = await provideReferences(usage, 36, { - includeDeclaration: true, - }); - - ok( - funDay, - "provideReferences returned null for a prefixed variable in a map", - ); - strictEqual( - funDay?.references.length, - 2, - "Expected two references to $day", - ); - - const [, one] = funDay.references; - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 3, - character: 15, - }, - end: { - line: 3, - character: 23, - }, - }); - - const hello = await provideReferences(usage, 64, { - includeDeclaration: true, - }); - ok( - hello, - "provideReferences returned null for a prefixed function in a map", - ); - strictEqual( - hello?.references.length, - 2, - "Expected two references to hello", - ); - }); - - it("provideReferences - excludes declaration if context says so", async () => { - await helpers.makeDocument( - ["@function hello() { @return 1; }", '$day: "monday";'], - - { - uri: "fun.scss", - }, - ); - - await helpers.makeDocument('@forward "fun" as fun-*;', { - uri: "dev.scss", - }); - - const usage = await helpers.makeDocument( - [ - '@use "dev";', - "", - "$map: (", - ' "gloomy": dev.$fun-day,', - ' "goodbye": dev.fun-hello(),', - ");", - ], - - { - uri: "one.scss", - }, - ); - - const funDay = await provideReferences(usage, 36, { - includeDeclaration: false, - }); - - ok( - funDay, - "provideReferences returned null for a variable excluding declarations", - ); - strictEqual(funDay?.references.length, 1, "Expected one reference to $day"); - - const hello = await provideReferences(usage, 64, { - includeDeclaration: false, - }); - ok( - hello, - "provideReferences returned null for a function excluding declarations", - ); - strictEqual(hello?.references.length, 1, "Expected one reference to hello"); - }); - - it("provideReferences - Sass built-in", async () => { - const usage = await helpers.makeDocument( - [ - '@use "sass:color";', - '$_color: color.scale($color: "#1b1917", $alpha: -75%);', - ".a {", - " color: $_color;", - " transform: scale(1.1);", - "}", - ], - - { - uri: "one.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "sass:color";', - '$_other-color: color.scale($color: "#1b1917", $alpha: -75%);', - ], - - { - uri: "two.scss", - }, - ); - - const references = await provideReferences(usage, 34, { - includeDeclaration: true, - }); - ok(references, "provideReferences returned null for Sass built-in"); - - strictEqual( - references?.references.length, - 2, - "Expected two references to scale", - ); - - const [one, two] = references.references; - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 1, - character: 15, - }, - end: { - line: 1, - character: 20, - }, - }); - - ok(two?.location.uri.endsWith("two.scss")); - deepStrictEqual(two?.location.range, { - start: { - line: 1, - character: 21, - }, - end: { - line: 1, - character: 26, - }, - }); - }); - - it("provideReferences - placeholders", async () => { - await helpers.makeDocument( - ["%alert {", " color: blue;", "}"], - - { - uri: "place.scss", - }, - ); - - const firstUsage = await helpers.makeDocument( - ['@use "place";', "", ".a {", " @extend %alert;", "}"], - - { - uri: "one.scss", - }, - ); - - await helpers.makeDocument( - [ - '@use "place";', - "", - ".a {", - " // Here it comes!", - " @extend %alert;", - "}", - ], - - { - uri: "two.scss", - }, - ); - - const actual = await provideReferences(firstUsage, 33, { - includeDeclaration: true, - }); - - ok(actual, "provideReferences returned null for a placeholder"); - strictEqual( - actual?.references.length, - 3, - "Expected three references to alert: two usages and one declaration", - ); - - const [place, one, two] = actual.references; - - ok(place?.location.uri.endsWith("place.scss")); - deepStrictEqual(place?.location.range, { - start: { - line: 0, - character: 0, - }, - end: { - line: 0, - character: 6, - }, - }); - - ok(one?.location.uri.endsWith("one.scss")); - deepStrictEqual(one?.location.range, { - start: { - line: 3, - character: 9, - }, - end: { - line: 3, - character: 15, - }, - }); - - ok(two?.location.uri.endsWith("two.scss")); - deepStrictEqual(two?.location.range, { - start: { - line: 4, - character: 9, - }, - end: { - line: 4, - character: 15, - }, - }); - }); -}); diff --git a/packages/language-server/test/features/signature-help.spec.ts b/packages/language-server/test/features/signature-help.spec.ts deleted file mode 100644 index a59e405c..00000000 --- a/packages/language-server/test/features/signature-help.spec.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { strictEqual, ok } from "assert"; -import { SymbolKind } from "vscode-languageserver"; -import type { SignatureHelp } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../src/context-provider"; -import { hasInFacts } from "../../src/features/signature-help/facts"; -import { doSignatureHelp } from "../../src/features/signature-help/signature-help"; -import { INode, ScssDocument } from "../../src/parser"; -import { getLanguageService } from "../../src/parser/language-service"; -import * as helpers from "../helpers"; - -async function getSignatureHelp(lines: string[]): Promise { - const text = lines.join("\n"); - - const document = await helpers.makeDocument(text); - const offset = text.indexOf("|"); - - return doSignatureHelp(document, offset); -} - -describe("Providers/SignatureHelp", () => { - beforeEach(() => { - helpers.createTestContext(); - - const document = TextDocument.create("./one.scss", "scss", 1, ""); - const ls = getLanguageService(); - const ast = ls.parseStylesheet(document) as INode; - - const { fs, storage } = useContext(); - - storage.set( - "one.scss", - new ScssDocument( - fs, - TextDocument.create("./one.scss", "scss", 1, ""), - { - variables: new Map(), - mixins: new Map([ - [ - "one", - { - name: "one", - kind: SymbolKind.Method, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "two", - { - name: "two", - kind: SymbolKind.Method, - parameters: [ - { name: "$a", value: null, offset: 0 }, - { name: "$b", value: null, offset: 0 }, - ], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - functions: new Map([ - [ - "make", - { - name: "make", - kind: SymbolKind.Function, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "one", - { - name: "one", - kind: SymbolKind.Function, - parameters: [ - { name: "$a", value: null, offset: 0 }, - { name: "$b", value: null, offset: 0 }, - { name: "$c", value: null, offset: 0 }, - ], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - [ - "two", - { - name: "two", - kind: SymbolKind.Function, - parameters: [ - { name: "$a", value: null, offset: 0 }, - { name: "$b", value: null, offset: 0 }, - ], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - imports: new Map(), - uses: new Map(), - forwards: new Map(), - placeholders: new Map(), - placeholderUsages: new Map(), - }, - ast, - ), - ); - }); - describe("Empty", () => { - it("Empty", async () => { - const actual = await getSignatureHelp(["@include one(|"]); - - strictEqual(actual.signatures.length, 1); - }); - it("Closed without parameters", async () => { - const actual = await getSignatureHelp(["@include two(|)"]); - - strictEqual(actual.signatures.length, 1); - }); - - it("Closed with parameters", async () => { - const actual = await getSignatureHelp(["@include two(1);"]); - - strictEqual(actual.signatures.length, 0); - }); - }); - - describe("Two parameters", () => { - it("Passed one parameter of two", async () => { - const actual = await getSignatureHelp(["@include two(1,|"]); - - strictEqual(actual.activeParameter, 1, "activeParameter"); - strictEqual(actual.signatures.length, 1, "signatures.length"); - }); - - it("Passed two parameter of two", async () => { - const actual = await getSignatureHelp(["@include two(1, 2,|"]); - - strictEqual(actual.activeParameter, 2, "activeParameter"); - strictEqual(actual.signatures.length, 1, "signatures.length"); - }); - - it("Passed three parameters of two", async () => { - const actual = await getSignatureHelp(["@include two(1, 2, 3,|"]); - - strictEqual(actual.signatures.length, 0); - }); - - it("Passed two parameter of two with parenthesis", async () => { - const actual = await getSignatureHelp(["@include two(1, 2)|"]); - - strictEqual(actual.signatures.length, 0); - }); - }); - - describe("parseArgumentsAtLine for Mixins", () => { - it("RGBA", async () => { - const actual = await getSignatureHelp([ - "@include two(rgba(0,0,0,.0001),|", - ]); - - strictEqual(actual.activeParameter, 1, "activeParameter"); - strictEqual(actual.signatures.length, 1, "signatures.length"); - }); - - it("RGBA when typing", async () => { - const actual = await getSignatureHelp(["@include two(rgba(0,0,0,|"]); - - strictEqual(actual.activeParameter, 0, "activeParameter"); - strictEqual(actual.signatures.length, 1, "signatures.length"); - }); - - it("Quotes", async () => { - const actual = await getSignatureHelp(['@include two("\\",;",|']); - - strictEqual(actual.activeParameter, 1, "activeParameter"); - strictEqual(actual.signatures.length, 1, "signatures.length"); - }); - - it("With overload", async () => { - const actual = await getSignatureHelp(["@include two(|"]); - - strictEqual(actual.signatures.length, 1); - }); - - it("Single-line selector", async () => { - const actual = await getSignatureHelp(["h1 { @include two(1,| }"]); - - strictEqual(actual.signatures.length, 1); - }); - - it("Single-line Mixin reference", async () => { - const actual = await getSignatureHelp([ - "h1 {", - " @include two(1, 2);", - " @include two(1,|)", - "}", - ]); - - strictEqual(actual.signatures.length, 1); - }); - - it("Mixin with named argument", async () => { - const actual = await getSignatureHelp(["@include two($a: 1,|"]); - - strictEqual(actual.signatures.length, 1); - }); - }); - - describe("parseArgumentsAtLine for Functions", () => { - it("Empty", async () => { - const actual = await getSignatureHelp(["content: make(|"]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - - it("Single-line Function reference", async () => { - const actual = await getSignatureHelp(["content: make()+make(|"]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - - it("Inside another uncompleted function", async () => { - const actual = await getSignatureHelp(["content: attr(make(|"]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - - it("Inside another completed function", async () => { - const actual = await getSignatureHelp([ - "content: attr(one(1, two(1, two(1, 2)),|", - ]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("one"), "name"); - }); - - it("Inside several completed functions", async () => { - const actual = await getSignatureHelp([ - "background: url(one(1, one(1, 2, two(1, 2)),|", - ]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("one"), "name"); - }); - - it("Inside another function with CSS function", async () => { - const actual = await getSignatureHelp(["background-color: make(rgba(|"]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - - it("Inside another function with uncompleted CSS function", async () => { - const actual = await getSignatureHelp([ - "background-color: make(rgba(1, 1,2,|", - ]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - - it("Inside another function with completed CSS function", async () => { - const actual = await getSignatureHelp([ - "background-color: make(rgba(1,2, 3,.5)|", - ]); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - - it("Interpolation", async () => { - const actual = await getSignatureHelp(['background-color: "#{make(|}"']); - - strictEqual(actual.signatures.length, 1, "length"); - ok(actual.signatures[0]?.label.startsWith("make"), "name"); - }); - }); - - describe("Utils/Facts", () => { - it("Contains", () => { - ok(hasInFacts("rgba")); - ok(hasInFacts("selector-nest")); - ok(hasInFacts("quote")); - }); - - it("Not contains", () => { - ok(!hasInFacts("hello")); - ok(!hasInFacts("from")); - ok(!hasInFacts("panda")); - }); - }); -}); diff --git a/packages/language-server/test/features/workspace-symbol.spec.ts b/packages/language-server/test/features/workspace-symbol.spec.ts deleted file mode 100644 index c7cc9610..00000000 --- a/packages/language-server/test/features/workspace-symbol.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { strictEqual } from "assert"; -import { SymbolKind } from "vscode-languageserver"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../src/context-provider"; -import { searchWorkspaceSymbol } from "../../src/features/workspace-symbols/workspace-symbol"; -import { INode, ScssDocument } from "../../src/parser"; -import { getLanguageService } from "../../src/parser/language-service"; -import * as helpers from "../helpers"; - -describe("Providers/WorkspaceSymbol", () => { - beforeEach(() => { - helpers.createTestContext(); - - const document = TextDocument.create("./one.scss", "scss", 1, ""); - const ls = getLanguageService(); - const ast = ls.parseStylesheet(document) as INode; - - const { fs, storage } = useContext(); - - storage.set( - "one.scss", - new ScssDocument( - fs, - document, - { - variables: new Map([ - [ - "$a", - { - name: "$a", - kind: SymbolKind.Variable, - value: "1", - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - mixins: new Map([ - [ - "mixin", - { - name: "mixin", - kind: SymbolKind.Method, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - functions: new Map([ - [ - "make", - { - name: "make", - kind: SymbolKind.Function, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - imports: new Map(), - uses: new Map(), - forwards: new Map(), - placeholders: new Map([ - [ - "%alert", - { - name: "%alert", - kind: SymbolKind.Class, - parameters: [], - offset: 0, - position: { line: 1, character: 1 }, - }, - ], - ]), - placeholderUsages: new Map(), - }, - ast, - ), - ); - }); - - it("searchWorkspaceSymbol - Empty query", async () => { - const actual = await searchWorkspaceSymbol("", ""); - - strictEqual(actual.length, 4); - }); - - it("searchWorkspaceSymbol - query for variable", async () => { - const actual = await searchWorkspaceSymbol("$", ""); - - strictEqual(actual.length, 1); - }); - - it("searchWorkspaceSymbol - query for function", async () => { - const actual = await searchWorkspaceSymbol("ma", ""); - - strictEqual(actual.length, 1); - }); - - it("searchWorkspaceSymbol - query for mixin", async () => { - const actual = await searchWorkspaceSymbol("mi", ""); - - strictEqual(actual.length, 1); - }); - - it("searchWorkspaceSymbol - query for placeholder", async () => { - const actual = await searchWorkspaceSymbol("%", ""); - - strictEqual(actual.length, 1); - }); -}); diff --git a/packages/language-server/test/fixture-helper.ts b/packages/language-server/test/fixture-helper.ts deleted file mode 100644 index 88ab8296..00000000 --- a/packages/language-server/test/fixture-helper.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as path from "path"; -import { URI } from "vscode-uri"; - -function getDocPath(p: string) { - return path.resolve(__dirname, "./fixtures", p); -} - -export function getUri(p: string) { - return URI.file(getDocPath(p)); -} diff --git a/packages/language-server/test/fixtures/completion/multi-level-hide/colors/_index.scss b/packages/language-server/test/fixtures/completion/multi-level-hide/colors/_index.scss deleted file mode 100644 index 24240d5b..00000000 --- a/packages/language-server/test/fixtures/completion/multi-level-hide/colors/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./base" hide $color-white; diff --git a/packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_base.scss b/packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_base.scss deleted file mode 100644 index ca1785b5..00000000 --- a/packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_base.scss +++ /dev/null @@ -1,3 +0,0 @@ -$color-black: black; -$color-grey: grey; -$color-white: white; diff --git a/packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_index.scss b/packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_index.scss deleted file mode 100644 index 6b08d505..00000000 --- a/packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./base" hide $color-black; diff --git a/packages/language-server/test/fixtures/completion/multi-level-hide/styles.scss b/packages/language-server/test/fixtures/completion/multi-level-hide/styles.scss deleted file mode 100644 index 66ca69e6..00000000 --- a/packages/language-server/test/fixtures/completion/multi-level-hide/styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use "./colors"; - -$text-color: colors.| diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/_index.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/_index.scss deleted file mode 100644 index 88cf8e2e..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@forward "./branch-a" hide $color-white; -@forward "./branch-b"; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_base.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_base.scss deleted file mode 100644 index 933650ff..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_base.scss +++ /dev/null @@ -1 +0,0 @@ -$color-white: white; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_index.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_index.scss deleted file mode 100644 index e1106df5..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./base"; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_base.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_base.scss deleted file mode 100644 index 933650ff..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_base.scss +++ /dev/null @@ -1 +0,0 @@ -$color-white: white; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_index.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_index.scss deleted file mode 100644 index e1106df5..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./base"; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/styles.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-hide/styles.scss deleted file mode 100644 index 66ca69e6..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-hide/styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use "./colors"; - -$text-color: colors.| diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/_index.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/_index.scss deleted file mode 100644 index d0b8a13e..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@forward "./branch-a" show $color-black; -@forward "./branch-b"; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_base.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_base.scss deleted file mode 100644 index ca1785b5..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_base.scss +++ /dev/null @@ -1,3 +0,0 @@ -$color-black: black; -$color-grey: grey; -$color-white: white; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_index.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_index.scss deleted file mode 100644 index e1106df5..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./base"; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_base.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_base.scss deleted file mode 100644 index ca1785b5..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_base.scss +++ /dev/null @@ -1,3 +0,0 @@ -$color-black: black; -$color-grey: grey; -$color-white: white; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_index.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_index.scss deleted file mode 100644 index e1106df5..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./base"; diff --git a/packages/language-server/test/fixtures/completion/same-symbol-name-show/styles.scss b/packages/language-server/test/fixtures/completion/same-symbol-name-show/styles.scss deleted file mode 100644 index 66ca69e6..00000000 --- a/packages/language-server/test/fixtures/completion/same-symbol-name-show/styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use "./colors"; - -$text-color: colors.| diff --git a/packages/language-server/test/fixtures/multi-root/foldera/testA.scss b/packages/language-server/test/fixtures/multi-root/foldera/testA.scss deleted file mode 100644 index 2043e7b4..00000000 --- a/packages/language-server/test/fixtures/multi-root/foldera/testA.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use "../folderb/testB"; - -.a { - @include testB.mix(); -} diff --git a/packages/language-server/test/fixtures/multi-root/folderb/testB.scss b/packages/language-server/test/fixtures/multi-root/folderb/testB.scss deleted file mode 100644 index d2533b80..00000000 --- a/packages/language-server/test/fixtures/multi-root/folderb/testB.scss +++ /dev/null @@ -1,3 +0,0 @@ -@mixin mix() { - color: red; -} diff --git a/packages/language-server/test/fixtures/multi-root/multi-root.code-workspace b/packages/language-server/test/fixtures/multi-root/multi-root.code-workspace deleted file mode 100644 index 85216df5..00000000 --- a/packages/language-server/test/fixtures/multi-root/multi-root.code-workspace +++ /dev/null @@ -1,19 +0,0 @@ -{ - "folders": [ - { - "name": "root", - "path": "./" - }, - { - "name": "Folder A", - "path": "./foldera" - }, - { - "name": "Folder B", - "path": "./folderb" - }, - { - "path": "../e2e" - } - ] -} diff --git a/packages/language-server/test/fixtures/scanner/follow-links/namespace/_index.scss b/packages/language-server/test/fixtures/scanner/follow-links/namespace/_index.scss deleted file mode 100644 index 6f1c815d..00000000 --- a/packages/language-server/test/fixtures/scanner/follow-links/namespace/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@forward "./variables" as var-*; diff --git a/packages/language-server/test/fixtures/scanner/follow-links/namespace/_variables.scss b/packages/language-server/test/fixtures/scanner/follow-links/namespace/_variables.scss deleted file mode 100644 index f7f5e698..00000000 --- a/packages/language-server/test/fixtures/scanner/follow-links/namespace/_variables.scss +++ /dev/null @@ -1 +0,0 @@ -$var: 1px; diff --git a/packages/language-server/test/fixtures/scanner/follow-links/styles.scss b/packages/language-server/test/fixtures/scanner/follow-links/styles.scss deleted file mode 100644 index 266e07c8..00000000 --- a/packages/language-server/test/fixtures/scanner/follow-links/styles.scss +++ /dev/null @@ -1 +0,0 @@ -@use "namespace"; diff --git a/packages/language-server/test/fixtures/scanner/self-reference/styles.scss b/packages/language-server/test/fixtures/scanner/self-reference/styles.scss deleted file mode 100644 index d96224db..00000000 --- a/packages/language-server/test/fixtures/scanner/self-reference/styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use "./styles"; - -$var: "hmm"; diff --git a/packages/language-server/test/helpers.ts b/packages/language-server/test/helpers.ts deleted file mode 100644 index 8a2eec45..00000000 --- a/packages/language-server/test/helpers.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { resolve, join } from "path"; -import { Position, Range } from "vscode-css-languageservice"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { URI } from "vscode-uri"; -import { createContext, useContext } from "../src/context-provider"; -import { FileSystemProvider } from "../src/file-system"; -import { parseDocument, type INode } from "../src/parser"; -import { getLanguageService } from "../src/parser/language-service"; -import type { ISettings } from "../src/settings"; -import StorageService from "../src/storage"; -import { TestFileSystem } from "./test-file-system"; - -export interface MakeDocumentOptions { - uri?: string; - languageId?: string; - version?: number; -} - -export async function makeDocument( - lines: string[] | string, - options: MakeDocumentOptions = {}, -): Promise { - const text = Array.isArray(lines) ? lines.join("\n") : lines; - const workspaceRootPath = resolve(""); - const workspaceRootUri = URI.file(workspaceRootPath); - const uri = URI.file(join(process.cwd(), options.uri || "index.scss")); - const document = TextDocument.create( - uri.toString(), - options.languageId || "scss", - options.version || 1, - text, - ); - - const scssDocument = await parseDocument(document, workspaceRootUri); - const { storage } = useContext(); - storage.set(uri, scssDocument); - return document; -} - -export async function makeAst(lines: string[]): Promise { - const document = await makeDocument(lines); - const ls = getLanguageService(); - return ls.parseStylesheet(document) as INode; -} - -export function makeSameLineRange(line = 1, start = 1, end = 1): Range { - return Range.create(Position.create(line, start), Position.create(line, end)); -} - -export function makeSettings(options?: Partial): ISettings { - return { - scannerDepth: 30, - scannerExclude: ["**/.git", "**/node_modules", "**/bower_components"], - suggestionStyle: "all", - scanImportedFiles: true, - suggestAllFromOpenDocument: false, - suggestFromUseOnly: false, - suggestFunctionsInStringContextAfterSymbols: " (+-*%", - ...options, - }; -} - -export const createTestContext = (fsProvider?: FileSystemProvider): void => { - const storage = new StorageService(); - const fs = fsProvider || new TestFileSystem(storage); - - createContext({ - storage, - fs, - settings: makeSettings(), - editorSettings: { - insertSpaces: false, - indentSize: 2, - tabSize: 2, - }, - workspaceRoot: URI.parse(process.cwd()), - clientCapabilities: { - textDocument: { - completion: { - completionItem: { documentationFormat: ["markdown", "plaintext"] }, - }, - hover: { - contentFormat: ["markdown", "plaintext"], - }, - }, - }, - }); -}; diff --git a/packages/language-server/test/parser/ast.spec.ts b/packages/language-server/test/parser/ast.spec.ts deleted file mode 100644 index 3f3992ec..00000000 --- a/packages/language-server/test/parser/ast.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { strictEqual } from "assert"; -import { NodeType } from "../../src/parser"; -import { getNodeAtOffset, getParentNodeByType } from "../../src/parser/ast"; -import * as helpers from "../helpers"; - -describe("Utils/Ast", () => { - beforeEach(() => helpers.createTestContext()); - - it("getNodeAtOffset", async () => { - const ast = await helpers.makeAst([".a {}"]); - - const node = getNodeAtOffset(ast, 4); - - strictEqual(node?.type, NodeType.Declarations); - strictEqual(node?.getText(), "{}"); - }); - - it("getParentNodeByType", async () => { - const ast = await helpers.makeAst([".a {}"]); - - const node = getNodeAtOffset(ast, 4); - const parentNode = getParentNodeByType(node, NodeType.Ruleset); - - strictEqual(parentNode?.type, NodeType.Ruleset); - strictEqual(parentNode?.getText(), ".a {}"); - }); -}); diff --git a/packages/language-server/test/parser/cssNodes.spec.ts b/packages/language-server/test/parser/cssNodes.spec.ts deleted file mode 100644 index 9458edf1..00000000 --- a/packages/language-server/test/parser/cssNodes.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as assert from "assert"; -// @ts-expect-error Not exported as enum type -import { NodeType as CSSNodeType } from "vscode-css-languageservice/lib/umd/parser/cssNodes"; -import { NodeType } from "../../src/parser"; - -describe("NodeType", () => { - it("type definition is in sync with vscode-css-languageservices", () => { - const types = Object.entries(NodeType); - assert.ok(types.length); - - for (const [nodeName, enumValue] of Object.entries(NodeType)) { - // NodeType be synced with https://github.com/microsoft/vscode-css-languageservice/blob/main/src/parser/cssNodes.ts - assert.strictEqual( - CSSNodeType[nodeName], - enumValue, - `Expected ${nodeName} to have equal value to vscode-css-languageservice`, - ); - } - }); -}); diff --git a/packages/language-server/test/parser/parser.spec.ts b/packages/language-server/test/parser/parser.spec.ts deleted file mode 100644 index 44c872eb..00000000 --- a/packages/language-server/test/parser/parser.spec.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { strictEqual, deepStrictEqual, ok } from "assert"; -import { stub, SinonStub } from "sinon"; -import { FileType } from "vscode-css-languageservice"; -import { URI } from "vscode-uri"; -import { useContext } from "../../src/context-provider"; -import { - parseDocument, - reForward, - reModuleAtRule, - rePlaceholderUsage, - reUse, -} from "../../src/parser"; -import * as helpers from "../helpers"; - -describe("Services/Parser", () => { - describe(".parseDocument", () => { - let statStub: SinonStub; - let fileExistsStub: SinonStub; - - beforeEach(() => { - helpers.createTestContext(); - const { fs } = useContext(); - fileExistsStub = stub(fs, "exists"); - statStub = stub(fs, "stat").yields(null, { - type: FileType.Unknown, - ctime: -1, - mtime: -1, - size: -1, - }); - }); - - afterEach(() => { - fileExistsStub.restore(); - statStub.restore(); - }); - - it("should return symbols", async () => { - const document = await helpers.makeDocument([ - '$name: "value";', - "@mixin mixin($a: 1, $b) {}", - "@function function($a: 1, $b) {}", - "%placeholder { color: blue; }", - ]); - - const symbols = await parseDocument(document, URI.parse("")); - - // Variables - const variables = [...symbols.variables.values()]; - strictEqual(variables.length, 1); - - strictEqual(variables[0]?.name, "$name"); - strictEqual(variables[0]?.value, '"value"'); - - // Mixins - const mixins = [...symbols.mixins.values()]; - strictEqual(mixins.length, 1); - - strictEqual(mixins[0]?.name, "mixin"); - strictEqual(mixins[0]?.parameters.length, 2); - - strictEqual(mixins[0]?.parameters[0]?.name, "$a"); - strictEqual(mixins[0]?.parameters[0]?.value, "1"); - - strictEqual(mixins[0]?.parameters[1]?.name, "$b"); - strictEqual(mixins[0]?.parameters[1]?.value, null); - - // Functions - const functions = [...symbols.functions.values()]; - strictEqual(functions.length, 1); - - strictEqual(functions[0]?.name, "function"); - strictEqual(functions[0]?.parameters.length, 2); - - strictEqual(functions[0]?.parameters[0]?.name, "$a"); - strictEqual(functions[0]?.parameters[0]?.value, "1"); - - strictEqual(functions[0]?.parameters[1]?.name, "$b"); - strictEqual(functions[0]?.parameters[1]?.value, null); - - // Placeholders - const placeholders = [...symbols.placeholders.values()]; - strictEqual(placeholders.length, 1); - - strictEqual(placeholders[0]?.name, "%placeholder"); - }); - - it("should return placeholder usages", async () => { - const document = await helpers.makeDocument([ - ".app-asdfqwer1234 {", - " @extend %app !optional;", - "}", - ]); - - const symbols = await parseDocument(document, URI.parse("")); - const usages = [...symbols.placeholderUsages.values()]; - strictEqual(usages.length, 1); - - strictEqual(usages[0]?.name, "%app"); - }); - - it("should return links", async () => { - fileExistsStub.resolves(true); - - await helpers.makeDocument(["$var: 1px;"], { - uri: "variables.scss", - }); - await helpers.makeDocument(["$tr: 2px;"], { - uri: "corners.scss", - }); - await helpers.makeDocument(["$b: #000;"], { - uri: "color.scss", - }); - - const document = await helpers.makeDocument([ - '@use "variables" as vars;', - '@use "corners" as *;', - '@forward "colors" as color-* hide $varslingsfarger, varslingsfarge;', - "%alert { color: blue; }", - ]); - - const symbols = await parseDocument(document, URI.parse("")); - - // Uses - const uses = [...symbols.uses.values()]; - strictEqual(uses.length, 2, "expected to find two uses"); - strictEqual(uses[0]?.namespace, "vars"); - strictEqual(uses[0]?.isAliased, true); - - strictEqual(uses[1]?.namespace, "*"); - strictEqual(uses[1]?.isAliased, true); - - // Forward - const forwards = [...symbols.forwards.values()]; - strictEqual(forwards.length, 1, "expected to find one forward"); - strictEqual(forwards[0]?.prefix, "color-"); - deepStrictEqual(forwards[0]?.hide, [ - "$varslingsfarger", - "varslingsfarge", - ]); - - // Placeholder - const placeholders = [...symbols.placeholders.values()]; - strictEqual(placeholders.length, 1, "expected to find one placeholder"); - strictEqual(placeholders[0]?.name, "%alert"); - }); - - it("should return relative links", async () => { - fileExistsStub.resolves(true); - - await helpers.makeDocument(["$var: 1px;"], { - uri: "upper.scss", - }); - await helpers.makeDocument(["$b: #000;"], { - uri: "middle/middle.scss", - }); - await helpers.makeDocument(["$tr: 2px;"], { - uri: "middle/lower/lower.scss", - }); - - const document = await helpers.makeDocument( - ['@use "../upper";', '@use "./middle";', '@use "./lower/lower";'], - - { uri: "middle/main.scss" }, - ); - - const symbols = await parseDocument(document, URI.parse("")); - const uses = [...symbols.uses.values()]; - - strictEqual(uses.length, 3, "expected to find three uses"); - }); - - it("should not crash on link to the same document", async () => { - const document = await helpers.makeDocument( - ['@use "./self";', "$var: 1px;"], - - { - uri: "self.scss", - }, - ); - const symbols = await parseDocument(document, URI.parse("")); - const uses = [...symbols.uses.values()]; - const variables = [...symbols.variables.values()]; - - strictEqual(variables.length, 1, "expected to find one variable"); - strictEqual(uses.length, 0, "expected to find no use link to self"); - }); - }); - - describe("regular expressions", () => { - it("for detecting module at rules", () => { - ok(reModuleAtRule.test('@use "file";'), "should match a basic @use"); - ok( - reModuleAtRule.test(' @use "file";'), - "should match an indented @use", - ); - ok( - reModuleAtRule.test('@use "~file";'), - "should match @use from node_modules", - ); - ok( - reModuleAtRule.test("@use 'file';"), - "should match @use with single quotes", - ); - ok( - reModuleAtRule.test('@use "../file";'), - "should match relative @use one level up", - ); - ok( - reModuleAtRule.test('@use "../../../file";'), - "should match relative @use several levels up", - ); - ok( - reModuleAtRule.test('@use "./file/other";'), - "should match relative @use one level down", - ); - ok( - reModuleAtRule.test('@use "./file/yet/another";'), - "should match relative @use several levels down", - ); - - ok( - reModuleAtRule.test('@forward "file";'), - "should match a basic @forward", - ); - ok( - reModuleAtRule.test(' @forward "file";'), - "should match an indented @forward", - ); - ok( - reModuleAtRule.test('@forward "~file";'), - "should match @forward from node_modules", - ); - ok( - reModuleAtRule.test("@forward 'file';"), - "should match @forward with single quotes", - ); - ok( - reModuleAtRule.test('@forward "../file";'), - "should match relative @forward one level up", - ); - ok( - reModuleAtRule.test('@forward "../../../file";'), - "should match relative @forward several levels up", - ); - ok( - reModuleAtRule.test('@forward "./file/other";'), - "should match relative @forward one level down", - ); - ok( - reModuleAtRule.test('@forward "./file/yet/another";'), - "should match relative @forward several levels down", - ); - - ok( - reModuleAtRule.test('@import "file";'), - "should match a basic @import", - ); - ok( - reModuleAtRule.test(' @import "file";'), - "should match an indented @import", - ); - ok( - reModuleAtRule.test('@import "~file";'), - "should match @import from node_modules", - ); - ok( - reModuleAtRule.test("@import 'file';"), - "should match @import with single quotes", - ); - ok( - reModuleAtRule.test('@import "../file";'), - "should match relative @import one level up", - ); - ok( - reModuleAtRule.test('@import "../../../file";'), - "should match relative @import several levels up", - ); - ok( - reModuleAtRule.test('@import "./file/other";'), - "should match relative @import one level down", - ); - ok( - reModuleAtRule.test('@import "./file/yet/another";'), - "should match relative @import several levels down", - ); - }); - - it("for use", () => { - ok(reUse.test('@use "file";'), "should match a basic @use"); - ok(reUse.test(' @use "file";'), "should match an indented @use"); - ok(reUse.test('@use "~file";'), "should match @use from node_modules"); - ok(reUse.test("@use 'file';"), "should match @use with single quotes"); - ok( - reUse.test('@use "../file";'), - "should match relative @use one level up", - ); - ok( - reUse.test('@use "../../../file";'), - "should match relative @use several levels up", - ); - ok( - reUse.test('@use "./file/other";'), - "should match relative @use one level down", - ); - ok( - reUse.test('@use "./file/yet/another";'), - "should match relative @use several levels down", - ); - - ok( - reUse.test('@use "variables" as vars;'), - "should match a @use with an alias", - ); - ok( - reUse.test('@use "src/corners" as *;'), - "should match a @use with a wildcard as alias", - ); - - const match = reUse.exec('@use "variables" as vars;'); - strictEqual(match!.groups!["url"] as string, "variables"); - strictEqual(match!.groups!["namespace"] as string, "vars"); - }); - - it("for forward", () => { - ok(reForward.test('@forward "file";'), "should match a basic @forward"); - ok( - reForward.test(' @forward "file";'), - "should match an indented @forward", - ); - ok( - reForward.test('@forward "~file";'), - "should match @forward from node_modules", - ); - ok( - reForward.test("@forward 'file';"), - "should match @forward with single quotes", - ); - ok( - reForward.test('@forward "../file";'), - "should match relative @forward one level up", - ); - ok( - reForward.test('@forward "../../../file";'), - "should match relative @forward several levels up", - ); - ok( - reForward.test('@forward "./file/other";'), - "should match relative @forward one level down", - ); - ok( - reForward.test('@forward "./file/yet/another";'), - "should match relative @forward several levels down", - ); - - ok( - reForward.test( - '@forward "colors" as color-* hide $varslingsfarger, varslingsfarge;', - ), - "should match a @forward with an alias and several hide", - ); - ok( - reForward.test('@forward "shadow";'), - "should match a @forward with no alias and no hide", - ); - ok( - reForward.test('@forward "spacing" hide $spacing-new;'), - "should match a @forward with no alias and a hide", - ); - - const match = reForward.exec( - '@forward "colors" as color-* hide $varslingsfarger, varslingsfarge;', - ); - strictEqual(match!.groups!["url"] as string, "colors"); - strictEqual(match!.groups!["prefix"] as string, "color-"); - strictEqual( - match!.groups!["hide"] as string, - "$varslingsfarger, varslingsfarge", - ); - }); - - it("for placeholder usages", () => { - strictEqual( - rePlaceholderUsage.exec("@extend %app;")!.groups!["name"], - "%app", - "should match a basic usage with space", - ); - - strictEqual( - rePlaceholderUsage.exec("@extend %app-name;")!.groups!["name"], - "%app-name", - "should match a basic usage with tab", - ); - - strictEqual( - rePlaceholderUsage.exec("@extend %spacing-2;")!.groups!["name"], - "%spacing-2", - "should match a basic usage with non-breaking space", - ); - - strictEqual( - rePlaceholderUsage.exec("@extend %placeholder !optional;")!.groups![ - "name" - ], - "%placeholder", - "should match optional", - ); - - strictEqual( - rePlaceholderUsage.exec(" @extend %down_low;")!.groups!["name"], - "%down_low", - "should match with indent", - ); - - strictEqual( - rePlaceholderUsage.exec(".app-asdfqwer1234 { @extend %placeholder;")! - .groups!["name"], - "%placeholder", - "should match on same line as class", - ); - }); - }); -}); diff --git a/packages/language-server/test/scanner/scanner.spec.ts b/packages/language-server/test/scanner/scanner.spec.ts deleted file mode 100644 index 20bd26cf..00000000 --- a/packages/language-server/test/scanner/scanner.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ok, strictEqual } from "assert"; -import { isMatch } from "micromatch"; -import { useContext } from "../../src/context-provider"; -import { NodeFileSystem } from "../../src/node-file-system"; -import ScannerService from "../../src/scanner"; -import { getUri } from "../fixture-helper"; -import * as helpers from "../helpers"; - -describe("Services/Scanner", () => { - beforeEach(() => { - helpers.createTestContext(new NodeFileSystem()); - }); - - it("should follow links", async () => { - const workspaceUri = getUri("scanner/follow-links/"); - const docUri = getUri("scanner/follow-links/styles.scss"); - const scanner = new ScannerService(); - await scanner.scan([docUri], workspaceUri); - - const { storage } = useContext(); - const documents = [...storage.values()]; - - strictEqual( - documents.length, - 3, - "expected to find three documents in fixtures/unit/scanner/follow-links/", - ); - }); - - it("should not get stuck in loops if the author links a document to itself", async () => { - // Yes, I've had this happen to me during a refactor :D - - const workspaceUri = getUri("scanner/self-reference/"); - const docUri = getUri("scanner/self-reference/styles.scss"); - const scanner = new ScannerService(); - await scanner.scan([docUri], workspaceUri); - - const { storage } = useContext(); - const documents = [...storage.values()]; - - strictEqual( - documents.length, - 1, - "expected to find a document in fixtures/unit/scanner/self-reference/", - ); - }); - - it("exclude matcher works as expected", () => { - ok(isMatch("/home/user/project/.git/index", "**/.git/**")); - ok( - isMatch( - "/home/user/project/node_modules/package/some.scss", - "**/node_modules/**", - ), - ); - }); -}); diff --git a/packages/language-server/test/test-file-system.ts b/packages/language-server/test/test-file-system.ts deleted file mode 100644 index 63a93bba..00000000 --- a/packages/language-server/test/test-file-system.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { promises } from "fs"; -import { FileStat, FileType } from "vscode-css-languageservice"; -import { URI, Utils } from "vscode-uri"; -import type { FileSystemProvider } from "../src/file-system"; -import type StorageService from "../src/storage"; - -export class TestFileSystem implements FileSystemProvider { - private readonly storage: StorageService; - - constructor(storage: StorageService) { - this.storage = storage; - } - - findFiles() { - return Promise.resolve( - [...this.storage.keys()].map((key) => URI.parse(key)), - ); - } - - async stat(uri: URI): Promise { - try { - const stats = await promises.stat(uri.fsPath); - let type = FileType.Unknown; - if (stats.isFile()) { - type = FileType.File; - } else if (stats.isDirectory()) { - type = FileType.Directory; - } else if (stats.isSymbolicLink()) { - type = FileType.SymbolicLink; - } - - return { - type, - ctime: stats.ctime.getTime(), - mtime: stats.mtime.getTime(), - size: stats.size, - }; - } catch (e) { - return { - type: FileType.Unknown, - ctime: -1, - mtime: -1, - size: -1, - }; - } - } - - readFile(uri: URI) { - const doc = this.storage.get(uri); - return Promise.resolve(doc?.getText() || ""); - } - - async readDirectory(uri: string): Promise<[string, FileType][]> { - const dir = await promises.readdir(uri); - const result: [string, FileType][] = []; - for (const file of dir) { - try { - const stats = await this.stat(Utils.joinPath(URI.parse(uri), file)); - result.push([file, stats.type]); - } catch (e) { - result.push([file, FileType.Unknown]); - } - } - return result; - } - - exists(uri: URI) { - return Promise.resolve(Boolean(this.storage.get(uri))); - } - - realPath(uri: URI) { - return Promise.resolve(uri); - } -} diff --git a/packages/language-server/test/utils/sassdoc.spec.ts b/packages/language-server/test/utils/sassdoc.spec.ts deleted file mode 100644 index e359661f..00000000 --- a/packages/language-server/test/utils/sassdoc.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { strictEqual } from "assert"; -import { SymbolKind } from "vscode-languageserver-types"; -import { ScssSymbol } from "../../src/parser"; -import { applySassDoc } from "../../src/utils/sassdoc"; - -describe("Utils/SassDoc", () => { - it("applySassDoc empty state", () => { - const noDoc: ScssSymbol = { - name: "test", - kind: SymbolKind.Property, - offset: 0, - position: { - character: 0, - line: 0, - }, - }; - strictEqual(applySassDoc(noDoc), ""); - }); - - it("omits name if identical to symbol name", () => { - const allDocs: ScssSymbol = { - name: "test", - kind: SymbolKind.Method, - offset: 0, - position: { - character: 0, - line: 0, - }, - sassdoc: { - commentRange: { - start: 0, - end: 0, - }, - context: { - code: "test", - line: { - start: 0, - end: 0, - }, - type: "mixin", - name: "test", - scope: "global", - }, - description: "This is a description", - name: "test", - }, - }; - strictEqual(applySassDoc(allDocs), `This is a description`); - }); - - it("omits access if public, even if defined", () => { - const allDocs: ScssSymbol = { - name: "test", - kind: SymbolKind.Method, - offset: 0, - position: { - character: 0, - line: 0, - }, - sassdoc: { - commentRange: { - start: 0, - end: 0, - }, - context: { - code: "test", - line: { - start: 0, - end: 0, - }, - type: "mixin", - name: "test", - scope: "global", - }, - description: "This is a description", - access: "public", - }, - }; - strictEqual(applySassDoc(allDocs), `This is a description`); - }); - - it("applySassDoc maximal state", () => { - const allDocs: ScssSymbol = { - name: "test", - kind: SymbolKind.Method, - offset: 0, - position: { - character: 0, - line: 0, - }, - sassdoc: { - commentRange: { - start: 0, - end: 0, - }, - context: { - code: "test", - line: { - start: 0, - end: 0, - }, - type: "mixin", - name: "test", - scope: "global", - }, - description: "This is a description", - access: "private", - alias: "alias", - aliased: ["test", "other-test"], - author: ["Johnny Appleseed", "Foo Bar"], - content: "Overrides for test defaults", - deprecated: "No, but yes for testing", - example: [ - { - code: "@include test;", - description: "Very helpful example", - type: "scss", - }, - ], - group: ["mixins", "helpers"], - ignore: ["this", "that"], - link: [ - { - url: "http://localhost:8080", - caption: "listen!", - }, - ], - name: "Test name", - output: "Things", - parameter: [ - { - name: "parameter", - default: "yes", - description: "helpful description", - type: "string", - }, - ], - property: [ - { - path: "foo/bar", - default: "yes", - description: "what", - name: "no", - type: "number", - }, - ], - require: [ - { - name: "other-test", - type: "string", - autofill: true, - description: "helpful description", - external: true, - url: "http://localhost:1337", - }, - ], - return: { type: "string", description: "helpful result" }, - see: [ - { - name: "other-thing", - commentRange: { - start: 0, - end: 0, - }, - context: { - code: "test", - line: { - start: 0, - end: 0, - }, - type: "mixin", - name: "test", - scope: "global", - }, - description: "This is a description", - }, - ], - since: [ - { - version: "0.0.1", - description: "The beginning of time", - }, - ], - throws: ["a fit"], - todo: ["nothing"], - type: ["color", "string"], - usedBy: [ - { - name: "other-thing", - commentRange: { - start: 0, - end: 0, - }, - context: { - code: "test", - line: { - start: 0, - end: 0, - }, - type: "mixin", - name: "test", - scope: "global", - }, - description: "This is a description", - }, - ], - }, - }; - - strictEqual( - applySassDoc(allDocs), - `This is a description - -@deprecated No, but yes for testing - -@name Test name - -@param string\`parameter\` [yes] - helpful description - -@type color,string - -@prop {number}\`foo/bar\` [yes] - what - -@content Overrides for test defaults - -@output Things - -@return string - helpful result - -@throw a fit - -@require {string}\`other-test\` - helpful description http://localhost:1337 - -@alias \`alias\` - -@see \`other-thing\` - -@since 0.0.1 - The beginning of time - -@author Johnny Appleseed - -@author Foo Bar - -[listen!](http://localhost:8080) - -@example Very helpful example - -\`\`\`scss -@include test; -\`\`\` - -@access private - -@group mixins, helpers - -@todo nothing`, - ); - }); -}); diff --git a/packages/language-server/test/utils/string.spec.ts b/packages/language-server/test/utils/string.spec.ts deleted file mode 100644 index 59a7d5ba..00000000 --- a/packages/language-server/test/utils/string.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { strictEqual } from "assert"; -import { - getCurrentWord, - getTextBeforePosition, - getTextAfterPosition, - asDollarlessVariable, - stripTrailingComma, -} from "../../src/utils/string"; - -describe("Utils/String", () => { - it("getCurrentWord", () => { - const text = ".text($a) {}"; - - strictEqual(getCurrentWord(text, 5), ".text"); - strictEqual(getCurrentWord(text, 8), "$a"); - }); - - it("getTextBeforePosition", () => { - const text = "\n.text($a) {}"; - - strictEqual(getTextBeforePosition(text, 6), ".text"); - strictEqual(getTextBeforePosition(text, 9), ".text($a"); - }); - - it("getTextAfterPosition", () => { - const text = ".text($a) {}"; - - strictEqual(getTextAfterPosition(text, 5), "($a) {}"); - strictEqual(getTextAfterPosition(text, 8), ") {}"); - }); - - it("asDollarlessVariable", () => { - strictEqual(asDollarlessVariable("$some-text"), "some-text"); - strictEqual(asDollarlessVariable("$someText"), "someText"); - strictEqual(asDollarlessVariable("$$$ (⌐■_■) $$$"), "$$ (⌐■_■) $$$"); - }); - - it("stripTrailingComma", () => { - strictEqual(stripTrailingComma("dev.$fun-day,"), "dev.$fun-day"); - strictEqual(stripTrailingComma("dev.$fun-day"), "dev.$fun-day"); - }); -}); diff --git a/packages/language-server/vitest.config.mts b/packages/language-server/vitest.config.mts new file mode 100644 index 00000000..e2df9da5 --- /dev/null +++ b/packages/language-server/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + }, +}); diff --git a/packages/language-server/webpack.config.js b/packages/language-server/webpack.config.js index cfba2e0a..ac4f71b0 100644 --- a/packages/language-server/webpack.config.js +++ b/packages/language-server/webpack.config.js @@ -34,6 +34,7 @@ const browserConfig = { events: require.resolve("events/"), path: require.resolve("path-browserify"), util: require.resolve("util/"), + url: require.resolve("url/"), "fs/promises": false, }, }, diff --git a/packages/language-services/README.md b/packages/language-services/README.md new file mode 100644 index 00000000..8b2d4417 --- /dev/null +++ b/packages/language-services/README.md @@ -0,0 +1,3 @@ +# @somesass/language-services + +Experimental wrapper around `@somesass/vscode-css-languageservice`. diff --git a/packages/language-services/package.json b/packages/language-services/package.json new file mode 100644 index 00000000..5899fd73 --- /dev/null +++ b/packages/language-services/package.json @@ -0,0 +1,66 @@ +{ + "name": "@somesass/language-services", + "version": "1.0.0", + "private": true, + "description": "The features powering some-sass-language-server", + "keywords": [ + "scss", + "sass" + ], + "engines": { + "node": ">=20" + }, + "homepage": "https://github.com/wkillerud/some-sass/blob/main/packages/language-services#readme", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/wkillerud/some-sass.git" + }, + "bugs": { + "url": "https://github.com/wkillerud/some-sass/issues" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "tag": "latest" + }, + "files": [ + "dist/", + "!dist/test/", + "!dist/**/*.test.js" + ], + "main": "dist/language-services.js", + "types": "dist/language-services.d.ts", + "exports": { + ".": { + "types": "./dist/language-services.d.ts", + "default": "./dist/language-services.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + }, + "./feature/*": { + "types": "./dist/feature/*.d.ts", + "default": "./dist/feature/*.js" + } + }, + "author": "William Killerud (https://www.williamkillerud.com/)", + "license": "MIT", + "scripts": { + "prepublishOnly": "npm run build", + "build": "tsc", + "clean": "shx rm -rf dist", + "test": "vitest", + "coverage": "vitest run --coverage" + }, + "dependencies": { + "@somesass/vscode-css-languageservice": "1.0.0", + "colorjs.io": "0.5.0", + "scss-sassdoc-parser": "3.1.0" + }, + "devDependencies": { + "@vitest/coverage-v8": "1.3.1", + "shx": "0.3.4", + "typescript": "5.3.3", + "vitest": "1.5.0" + } +} diff --git a/packages/language-server/src/features/sass-built-in-modules.ts b/packages/language-services/src/facts/sass.ts similarity index 100% rename from packages/language-server/src/features/sass-built-in-modules.ts rename to packages/language-services/src/facts/sass.ts diff --git a/packages/language-server/src/features/sassdoc-annotations.ts b/packages/language-services/src/facts/sassdoc.ts similarity index 97% rename from packages/language-server/src/features/sassdoc-annotations.ts rename to packages/language-services/src/facts/sassdoc.ts index ab8661c2..0a99b60e 100644 --- a/packages/language-server/src/features/sassdoc-annotations.ts +++ b/packages/language-services/src/facts/sassdoc.ts @@ -1,4 +1,4 @@ -import { InsertTextFormat } from "vscode-languageserver-types"; +import { InsertTextFormat } from "../language-services-types"; interface SassDocAnnotation { annotation: string; diff --git a/packages/language-services/src/features/__tests__/code-actions-extract.test.ts b/packages/language-services/src/features/__tests__/code-actions-extract.test.ts new file mode 100644 index 00000000..6734d17d --- /dev/null +++ b/packages/language-services/src/features/__tests__/code-actions-extract.test.ts @@ -0,0 +1,260 @@ +import { EOL } from "node:os"; +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { + CodeAction, + Position, + Range, + TextDocumentEdit, + TextEdit, +} from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); + ls.configure({}); // Reset any configuration to default +}); + +const getEdit = (result: CodeAction): TextEdit[] => { + const edit = result.edit; + if (!edit) return []; + + const changes = edit.documentChanges; + if (!changes) return []; + + const change = changes[0] as TextDocumentEdit | undefined; + if (!change) return []; + + return change.edits; +}; + +test("extraction for variable", async () => { + const document = fileSystemProvider.createDocument([ + "--var: black;", + ".a { color: var(--var); }", + ]); + + const result = await ls.getCodeActions( + document, + Range.create(Position.create(0, 7), Position.create(0, 12)), + ); + + assert.deepStrictEqual(getEdit(result[0]), [ + { + newText: `$_variable: black;${EOL}--var: $_variable`, + range: { + end: { + character: 12, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }, + }, + ]); +}); + +test("extraction for multiline variable", async () => { + const document = fileSystemProvider.createDocument([ + `box-shadow: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color),`, + ` 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%);`, + ]); + + const result = await ls.getCodeActions( + document, + Range.create(Position.create(0, 12), Position.create(1, 111)), + ); + + assert.deepStrictEqual(getEdit(result[0]), [ + { + newText: `$_variable: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color),${EOL} 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%);${EOL}box-shadow: $_variable;`, + range: { + end: { + character: 111, + line: 1, + }, + start: { + character: 0, + line: 0, + }, + }, + }, + ]); +}); + +test("extraction for mixin with tab indents", async () => { + ls.configure({ + editorSettings: { + insertSpaces: false, + }, + }); + + const document = fileSystemProvider.createDocument(` +a.cta { + color: var(--cta-text); + text-decoration: none; + + &:visited { + color: var(--cta-text); + } +}`); + + const result = await ls.getCodeActions( + document, + Range.create(Position.create(2, 1), Position.create(7, 2)), + ); + + assert.deepStrictEqual(getEdit(result[1]), [ + { + newText: `@mixin _mixin { + color: var(--cta-text); + text-decoration: none; + + &:visited { + color: var(--cta-text); + } + } + @include _mixin;`, + range: { + end: { + character: 2, + line: 7, + }, + start: { + character: 1, + line: 2, + }, + }, + }, + ]); +}); + +test("extraction for mixin with space indents", async () => { + ls.configure({ + editorSettings: { + insertSpaces: true, + indentSize: 4, + }, + }); + + const document = fileSystemProvider.createDocument(` +a.cta { + color: var(--cta-text); + text-decoration: none; + + &:visited { + color: var(--cta-text); + } +}`); + + const result = await ls.getCodeActions( + document, + Range.create(Position.create(2, 4), Position.create(7, 5)), + ); + + assert.deepStrictEqual(getEdit(result[1]), [ + { + newText: `@mixin _mixin { + color: var(--cta-text); + text-decoration: none; + + &:visited { + color: var(--cta-text); + } + } + @include _mixin;`, + range: { + end: { + character: 5, + line: 7, + }, + start: { + character: 4, + line: 2, + }, + }, + }, + ]); +}); + +test("extraction for function with tab indents", async () => { + ls.configure({ + editorSettings: { + insertSpaces: false, + }, + }); + + const document = fileSystemProvider.createDocument(` +box-shadow: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), + 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); +`); + + const result = await ls.getCodeActions( + document, + Range.create(Position.create(1, 12), Position.create(2, 111)), + ); + + assert.deepStrictEqual(getEdit(result[2]), [ + { + newText: `@function _function() { + @return inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), + 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); +} +box-shadow: _function();`, + range: { + end: { + character: 111, + line: 2, + }, + start: { + character: 0, + line: 1, + }, + }, + }, + ]); +}); + +test("extraction for function with space indents", async () => { + ls.configure({ + editorSettings: { + insertSpaces: true, + indentSize: 2, + }, + }); + + const document = fileSystemProvider.createDocument(` +box-shadow: inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), + 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); +`); + + const result = await ls.getCodeActions( + document, + Range.create(Position.create(1, 12), Position.create(2, 112)), + ); + + assert.deepStrictEqual(getEdit(result[2]), [ + { + newText: `@function _function() { + @return inset 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), + 0 0 0 jkl.rem(1px) var(--jkl-calendar-border-color), jkl.rem(2px) jkl.rem(4px) jkl.rem(16px) rgb(0 0 0 / 24%); +} +box-shadow: _function();`, + range: { + end: { + character: 112, + line: 2, + }, + start: { + character: 0, + line: 1, + }, + }, + }, + ]); +}); diff --git a/packages/language-services/src/features/__tests__/do-complete-embedded.test.ts b/packages/language-services/src/features/__tests__/do-complete-embedded.test.ts new file mode 100644 index 00000000..d66834d9 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-complete-embedded.test.ts @@ -0,0 +1,134 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { + CompletionItemKind, + Position, + TextDocument, +} from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +const reSassExt = /\.s(a|c)ss$/; + +type Region = [number, number]; + +function getStylesheetRegions(content: string) { + const regions: Region[] = []; + const startRe = + //g; + const endRe = /<\/style>/g; + let start: RegExpExecArray | null; + let end: RegExpExecArray | null; + while ( + (start = startRe.exec(content)) !== null && + (end = endRe.exec(content)) !== null + ) { + if (start[0] !== undefined) { + regions.push([start.index + start[0].length, end.index]); + } + } + return regions; +} + +function getStylesheetContent(content: string, regions: Region[]) { + const oldContent = content; + let newContent = oldContent + .split("\n") + .map((line) => " ".repeat(line.length)) + .join("\n"); + for (const r of regions) { + newContent = + newContent.slice(0, r[0]) + + oldContent.slice(r[0], r[1]) + + newContent.slice(r[1]); + } + return newContent; +} + +function getSCSSRegionsDocument(document: TextDocument, position?: Position) { + if (document.uri.match(reSassExt)) { + return document; + } + + const offset = position ? document.offsetAt(position) : 0; + const text = document.getText(); + const stylesheetRegions = getStylesheetRegions(text); + + if ( + typeof position === "undefined" || + stylesheetRegions.some( + (region) => region[0] <= offset && region[1] >= offset, + ) + ) { + const uri = document.uri; + const version = document.version; + + return TextDocument.create( + uri, + "scss", + version, + getStylesheetContent(text, stylesheetRegions), + ); + } + + return document; +} + +beforeEach(() => { + ls.clearCache(); + ls.configure({}); // Reset any configuration to default +}); + +test("should suggest symbol from a different document via @use", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const vue = fileSystemProvider.createDocument( + [ + "", + "", + '", + ], + { + uri: "two.vue", + }, + ); + + const position = Position.create(9, 11); + const two = getSCSSRegionsDocument(vue, position); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, position); + assert.notEqual( + 0, + items.length, + "Expected to find a completion item for $primary", + ); + assert.deepStrictEqual( + items.find((annotation) => annotation.label === "$primary"), + { + commitCharacters: [";", ","], + documentation: "limegreen\n____\nVariable declared in one.scss", + filterText: "ns.$primary", + insertText: "$primary", + kind: CompletionItemKind.Color, + label: "$primary", + sortText: undefined, + tags: [], + }, + ); +}); diff --git a/packages/language-services/src/features/__tests__/do-complete-import.test.ts b/packages/language-services/src/features/__tests__/do-complete-import.test.ts new file mode 100644 index 00000000..0f26e29d --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-complete-import.test.ts @@ -0,0 +1,69 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("suggests built-in sass modules for imports", async () => { + const document = fileSystemProvider.createDocument('@use "'); + const { items } = await ls.doComplete(document, Position.create(0, 6)); + assert.notEqual( + items.length, + 0, + "Expected to get built-in Sass modules as completions", + ); + + // Quick sampling of the results + assert.ok( + items.find((annotation) => annotation.label === "sass:color"), + "Expected to find sass:color entry", + ); +}); + +test("suggests subdirectories from node_modules module", async () => { + fileSystemProvider.createDocument("", { + uri: "./node_modules/bootstrap/scss/bootstrap.scss", + languageId: "scss", + }); + fileSystemProvider.createDocument("", { + uri: "./node_modules/bootstrap/package.json", + languageId: "json", + }); + const document = fileSystemProvider.createDocument('@use "bootstrap/'); + const { items } = await ls.doComplete(document, Position.create(0, 6)); + + assert.notEqual(items.length, 0, "Expected to get completions"); + + assert.ok( + items.find((annotation) => annotation.label === "scss/"), + `Expected to find scss/ entry, got ${JSON.stringify(items, null, 2)}`, + ); +}); + +test("suggests files from node_modules module", async () => { + fileSystemProvider.createDocument("", { + uri: "./node_modules/bootstrap/scss/bootstrap.scss", + languageId: "scss", + }); + fileSystemProvider.createDocument("", { + uri: "./node_modules/bootstrap/package.json", + languageId: "json", + }); + const document = fileSystemProvider.createDocument('@use "bootstrap/scss/'); + const { items } = await ls.doComplete(document, Position.create(0, 6)); + + assert.notEqual(items.length, 0, "Expected to get completions"); + + assert.ok( + items.find((annotation) => annotation.label === "bootstrap.scss"), + `Expected to find bootstrap.scss entry, got ${JSON.stringify(items, null, 2)}`, + ); +}); + +test.todo("suggests files from pkg: imports"); diff --git a/packages/language-services/src/features/__tests__/do-complete-modules.test.ts b/packages/language-services/src/features/__tests__/do-complete-modules.test.ts new file mode 100644 index 00000000..b7bff960 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-complete-modules.test.ts @@ -0,0 +1,938 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { + CompletionItemKind, + InsertTextFormat, + Position, +} from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); + ls.configure({}); // Reset any configuration to default +}); + +test("suggests built-in sass modules", async () => { + const document = fileSystemProvider.createDocument([ + '@use "sass:math";', + "$var: math.", + ]); + const { items } = await ls.doComplete(document, Position.create(1, 11)); + assert.notEqual( + items.length, + 0, + "Expected to get completions from the sass:math module", + ); + + // Quick sampling of the results + assert.deepStrictEqual( + items.find((annotation) => annotation.label === "$pi"), + { + documentation: { + kind: "markdown", + value: + "The value of the mathematical constant **π**.\n\n[Sass documentation](https://sass-lang.com/documentation/modules/math#$pi)", + }, + filterText: "math.$pi", + insertText: ".$pi", + insertTextFormat: InsertTextFormat.PlainText, + kind: CompletionItemKind.Variable, + label: "$pi", + labelDetails: { + detail: undefined, + }, + }, + ); +}); + +test("should suggest symbol from a different document via @use", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', ".a { color: one."], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 16)); + assert.notEqual( + 0, + items.length, + "Expected to find a completion item for $primary", + ); + assert.deepStrictEqual( + items.find((annotation) => annotation.label === "$primary"), + { + commitCharacters: [";", ","], + documentation: "limegreen\n____\nVariable declared in one.scss", + filterText: "one.$primary", + insertText: ".$primary", + kind: CompletionItemKind.Color, + label: "$primary", + sortText: undefined, + tags: [], + }, + ); +}); + +test("should suggest symbol from a different document via @use when in string interpolation", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', '.a { background: url("/#{one.'], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 29)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest symbol from a different document via @use when in @return", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', "@function test() { @return one."], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 31)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest symbol from a different document via @use when in @if", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument(['@use "./one";', "@if one."], { + uri: "two.scss", + }); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 8)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest symbol from a different document via @use when in @else if", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', "@if $foo {", "} @else if one."], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(2, 15)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest symbol from a different document via @use when in @each", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', "@each $foo in one."], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest symbol from a different document via @use when in @for", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', "@for $i from one."], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest symbol from a different document via @use when in @wile", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', "@while $i > one.$"], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.ok(items.find((annotation) => annotation.label === "$primary")); +}); + +test("should suggest prefixed symbol from a different document via @use and @forward", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument('@forward "./one" as foo-*;', { + uri: "two.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "./two";', ".a { color: two."], + { + uri: "three.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const { items } = await ls.doComplete(three, Position.create(1, 16)); + assert.notEqual( + 0, + items.length, + "Expected to find a completion item for $foo-primary", + ); + assert.deepStrictEqual( + items.find((annotation) => annotation.label === "$foo-primary"), + { + commitCharacters: [";", ","], + documentation: "limegreen\n____\nVariable declared in one.scss", + filterText: "two.$foo-primary", + insertText: ".$foo-primary", + kind: CompletionItemKind.Color, + label: "$foo-primary", + sortText: undefined, + tags: [], + }, + ); +}); + +test("should not include hidden symbol if configured", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["$primary: limegreen;", "$secret: red;"], + { + uri: "one.scss", + }, + ); + const two = fileSystemProvider.createDocument( + '@forward "./one" hide $secret;', + { + uri: "two.scss", + }, + ); + const three = fileSystemProvider.createDocument( + ['@use "./two";', ".a { color: two."], + { + uri: "three.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const { items } = await ls.doComplete(three, Position.create(1, 16)); + assert.notExists( + items.find((item) => item.label === "$secret"), + "Expected not to find hidden symbol $secret", + ); +}); + +test("should not include private symbol", async () => { + const one = fileSystemProvider.createDocument( + ["$primary: limegreen;", "$_private: red;"], + { + uri: "one.scss", + }, + ); + const two = fileSystemProvider.createDocument('@forward "./one";', { + uri: "two.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "./two";', ".a { color: two."], + { + uri: "three.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const { items } = await ls.doComplete(three, Position.create(1, 16)); + assert.notExists( + items.find((item) => item.label === "$_private"), + "Expected not to find hidden symbol $_private", + ); +}); + +test("should only include shown symbol if configured", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["$primary: limegreen;", "$secondary: yellow;", "$public: red;"], + { + uri: "one.scss", + }, + ); + const two = fileSystemProvider.createDocument( + '@forward "./one" show $public;', + { + uri: "two.scss", + }, + ); + const three = fileSystemProvider.createDocument( + ['@use "./two";', ".a { color: two."], + { + uri: "three.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const { items } = await ls.doComplete(three, Position.create(1, 16)); + assert.exists( + items.find((item) => item.label === "$public"), + "Expected to find shown symbol $public", + ); + assert.notExists( + items.find((item) => item.label === "$primary"), + "Expected not to find hidden symbol $primary", + ); + assert.notExists( + items.find((item) => item.label === "$secondary"), + "Expected not to find hidden symbol $secondary", + ); +}); + +test("should suggest mixin with no parameter", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@mixin primary() { color: $primary; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { @include one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.find((item) => item.label === "primary"), + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary()\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: undefined, + sortText: undefined, + tags: [], + }, + ); +}); + +test("should suggest mixin with optional parameter", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@mixin primary($color: red) { color: $color; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { @include one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.filter((item) => item.label === "primary"), + [ + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($color: red)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:color})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($color: red)" }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($color: red)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:color}) {\n\t$0\n}", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($color: red) { }" }, + sortText: undefined, + tags: [], + }, + ], + ); +}); + +test("should suggest mixin with required parameter", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@mixin primary($color) { color: $color; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { @include one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.filter((item) => item.label === "primary"), + [ + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($color)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:color})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($color)" }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($color)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:color}) {\n\t$0\n}", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($color) { }" }, + sortText: undefined, + tags: [], + }, + ], + ); +}); + +test("given both required and optional parameters should suggest two variants of mixin - one with all parameters and one with only required", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + [ + "@mixin primary($background, $color: red) { color: $color; background-color: $background; }", + ], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { @include one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.filter((item) => item.label === "primary"), + [ + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($background, $color: red)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:background})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($background)" }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($background, $color: red)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:background}) {\n\t$0\n}", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($background) { }" }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($background, $color: red)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:background}, ${2:color})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($background, $color: red)" }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: + "```scss\n@mixin primary($background, $color: red)\n```\n____\nMixin declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:background}, ${2:color}) {\n\t$0\n}", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Method, + label: "primary", + labelDetails: { detail: "($background, $color: red) { }" }, + sortText: undefined, + tags: [], + }, + ], + ); +}); + +test("should suggest function with no parameter", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@function primary() { @return $primary; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.find((item) => item.label === "primary"), + { + documentation: { + kind: "markdown", + value: + "```scss\n@function primary()\n```\n____\nFunction declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary()", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Function, + label: "primary", + labelDetails: { detail: "()" }, + sortText: undefined, + tags: [], + }, + ); +}); + +test("should suggest function with optional parameter", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@function primary($color: red) { @return $color; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.find((item) => item.label === "primary"), + { + documentation: { + kind: "markdown", + value: + "```scss\n@function primary($color: red)\n```\n____\nFunction declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary()", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Function, + label: "primary", + labelDetails: { detail: "()" }, + sortText: undefined, + tags: [], + }, + ); +}); + +test("should suggest function with required parameter", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@function primary($color) { @return $color; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.find((item) => item.label === "primary"), + { + documentation: { + kind: "markdown", + value: + "```scss\n@function primary($color)\n```\n____\nFunction declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:color})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Function, + label: "primary", + labelDetails: { detail: "($color)" }, + sortText: undefined, + tags: [], + }, + ); +}); + +test("given both required and optional parameters should suggest two variants of function - one with all parameters and one with only required", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument( + ["@function primary($a, $b: 1) { @return $a * $b; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 18)); + assert.deepStrictEqual( + items.filter((item) => item.label === "primary"), + [ + { + documentation: { + kind: "markdown", + value: + "```scss\n@function primary($a, $b: 1)\n```\n____\nFunction declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:a})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Function, + label: "primary", + labelDetails: { detail: "($a)" }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: + "```scss\n@function primary($a, $b: 1)\n```\n____\nFunction declared in one.scss", + }, + filterText: "one.primary", + insertText: ".primary(${1:a}, ${2:b})", + insertTextFormat: InsertTextFormat.Snippet, + kind: CompletionItemKind.Function, + label: "primary", + labelDetails: { detail: "($a, $b: 1)" }, + sortText: undefined, + tags: [], + }, + ], + ); +}); + +test("should suggest all symbols as legacy @import may be in use", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument(".a { color: ", { + uri: "two.scss", + }); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(0, 12)); + assert.notEqual( + 0, + items.length, + "Expected to find a completion item for $primary", + ); + assert.deepStrictEqual( + items.find((annotation) => annotation.label === "$primary"), + { + commitCharacters: [";", ","], + documentation: "limegreen\n____\nVariable declared in one.scss", + filterText: undefined, + insertText: undefined, + kind: CompletionItemKind.Color, + label: "$primary", + sortText: undefined, + tags: [], + }, + ); +}); + +test("should not suggest legacy @import symbols if configured", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument(".a { color: ", { + uri: "two.scss", + }); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(0, 12)); + assert.isUndefined( + items.find((annotation) => annotation.label === "$primary"), + "Expected not to find a suggestion for $primary", + ); +}); + +test("should suggest symbol from a different document via @use with wildcard alias", async () => { + ls.configure({ + completionSettings: { + suggestFromUseOnly: true, + }, + }); + + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one" as *;', ".a { color: "], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(1, 12)); + assert.notEqual( + 0, + items.length, + "Expected to find a completion item for $primary", + ); + assert.deepStrictEqual( + items.find((annotation) => annotation.label === "$primary"), + { + commitCharacters: [";", ","], + documentation: "limegreen\n____\nVariable declared in one.scss", + filterText: undefined, + insertText: undefined, + kind: CompletionItemKind.Color, + label: "$primary", + sortText: undefined, + tags: [], + }, + ); +}); diff --git a/packages/language-services/src/features/__tests__/do-complete-placeholders.test.ts b/packages/language-services/src/features/__tests__/do-complete-placeholders.test.ts new file mode 100644 index 00000000..fa9da064 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-complete-placeholders.test.ts @@ -0,0 +1,37 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { + CompletionItemKind, + InsertTextFormat, + Position, +} from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("when declaring a placeholder selector, suggest placeholders that have an @extend usage", async () => { + // https://github.com/wkillerud/some-sass/issues/49 + + const one = fileSystemProvider.createDocument(".main { @extend %main; }", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument("%"); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const { items } = await ls.doComplete(two, Position.create(0, 1)); + assert.deepStrictEqual(items[0], { + filterText: "main", + insertText: "main", + insertTextFormat: InsertTextFormat.PlainText, + kind: CompletionItemKind.Class, + label: "%main", + }); +}); diff --git a/packages/language-services/src/features/__tests__/do-complete-sassdoc.test.ts b/packages/language-services/src/features/__tests__/do-complete-sassdoc.test.ts new file mode 100644 index 00000000..cbe2964c --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-complete-sassdoc.test.ts @@ -0,0 +1,245 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("sassdoc comment block for mixin", async () => { + const document = fileSystemProvider.createDocument([ + "$a: 1;", + "", + "///", + "@mixin interactive() { color: blue; }", + ]); + + const { items } = await ls.doComplete(document, Position.create(2, 3)); + assert.equal(items.length, 1, "Expected to get a completion result"); + assert.deepStrictEqual(items[0], { + insertText: " ${0}\n/// @output ${1}", + insertTextFormat: 2, + label: "SassDoc Block", + sortText: "-", + }); +}); + +test("sassdoc comment block for mixin with parameters", async () => { + const document = fileSystemProvider.createDocument([ + "$a: 1;", + "", + "///", + "@mixin interactive($color: blue) { color: $color; }", + ]); + + const { items } = await ls.doComplete(document, Position.create(2, 3)); + assert.equal(items.length, 1, "Expected to get a completion result"); + assert.deepStrictEqual(items[0], { + insertText: + " ${0}\n/// @param {${1:type}} \\$color [blue] ${2:-}\n/// @output ${3}", + insertTextFormat: 2, + label: "SassDoc Block", + sortText: "-", + }); +}); + +test("sassdoc comment block for function with parameters", async () => { + const document = fileSystemProvider.createDocument([ + "$a: 1;", + "", + "///", + "@function interactive($color: blue) { @return $color; }", + ]); + + const { items } = await ls.doComplete(document, Position.create(2, 3)); + assert.equal(items.length, 1, "Expected to get a completion result"); + assert.deepStrictEqual(items[0], { + insertText: + " ${0}\n/// @param {${1:type}} \\$color [blue] ${2:-}\n/// @return {${3:type}} ${4:-}", + insertTextFormat: 2, + label: "SassDoc Block", + sortText: "-", + }); +}); + +test("sassdoc comment block for mixin with @content", async () => { + const document = fileSystemProvider.createDocument([ + "$a: 1;", + "", + "///", + "@mixin apply-to-ie6-only {", + " * html {", + " @content;", + " }", + "}", + ]); + + const { items } = await ls.doComplete(document, Position.create(2, 3)); + assert.equal(items.length, 1, "Expected to get a completion result"); + assert.deepStrictEqual(items[0], { + insertText: " ${0}\n/// @content ${1}\n/// @output ${2}", + insertTextFormat: 2, + label: "SassDoc Block", + sortText: "-", + }); +}); + +test("sassdoc comment block for mixin with parameters and @content", async () => { + const document = fileSystemProvider.createDocument([ + "$a: 1;", + "", + "///", + "@mixin apply-to-ie6-only($color: #fff, $visibility: hidden) {", + " * html {", + " color: $color;", + " visibility: $visibility;", + " @content;", + " }", + "}", + ]); + + const { items } = await ls.doComplete(document, Position.create(2, 3)); + assert.equal(items.length, 1, "Expected to get a completion result"); + assert.deepStrictEqual(items[0], { + insertText: + " ${0}\n/// @param {${1:Color}} \\$color [#fff] ${2:-}\n/// @param {${3:type}} \\$visibility [hidden] ${4:-}\n/// @content ${5}\n/// @output ${6}", + insertTextFormat: 2, + label: "SassDoc Block", + sortText: "-", + }); +}); + +test("sassdoc annotation values for @example", async () => { + const document = fileSystemProvider.createDocument("/// @example "); + const { items } = await ls.doComplete(document, Position.create(0, 13)); + assert.notEqual(items.length, 0, "Expected to get completion results"); + assert.deepStrictEqual(items, [ + { + kind: 12, + label: "scss", + sortText: "-", + }, + { + kind: 12, + label: "css", + }, + { + kind: 12, + label: "markup", + }, + { + kind: 12, + label: "javascript", + sortText: "y", + }, + ]); +}); + +test("sassdoc annotations", async () => { + const document = fileSystemProvider.createDocument("/// "); + const { items } = await ls.doComplete(document, Position.create(0, 4)); + assert.notEqual(items.length, 0, "Expected to get completion results"); + + // Quick sampling of the results + assert.ok( + items.find((annotation) => annotation.label === "@access"), + "Expected to find @access annotation", + ); + assert.ok( + items.find((annotation) => annotation.label === "@type"), + "Expected to find @type annotation", + ); +}); + +test("sassdoc string literal union type", async () => { + const one = fileSystemProvider.createDocument( + [ + "/// Get a timing value for use in animations.", + '/// @param {"sonic" | "link" | "homer" | "snorlax"} $mode - The timing you want', + "/// @return {String} - the timing value in ms", + "@function timing($mode) {", + " @if map.has-key($_timings, $mode) {", + " @return map.get($_timings, $mode);", + " } @else {", + " @error 'Unable to find a mode for #{$mode}';", + " }", + "}", + ], + { uri: "timing.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./timing" as t;', + ".a {", + " transition-duration: t.timi", + "}", + ]); + + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = await ls.doComplete(two, Position.create(2, 28)); + + assert.deepStrictEqual(result, { + isIncomplete: false, + items: [ + { + documentation: { + kind: "markdown", + value: `\`\`\`scss +@function timing($mode) +\`\`\` +____ +Get a timing value for use in animations. + + +@param "sonic" | "link" | "homer" | "snorlax"\`mode\` - The timing you want + +@return String - the timing value in ms +____ +Function declared in timing.scss`, + }, + filterText: "t.timing", + insertText: '.timing(${1|"sonic","link","homer","snorlax"|})', + insertTextFormat: 2, + kind: 3, + label: "timing", + labelDetails: { + detail: "($mode)", + }, + sortText: undefined, + tags: [], + }, + { + documentation: { + kind: "markdown", + value: `\`\`\`scss +@function timing($mode) +\`\`\` +____ +Get a timing value for use in animations. + + +@param "sonic" | "link" | "homer" | "snorlax"\`mode\` - The timing you want + +@return String - the timing value in ms +____ +Function declared in timing.scss`, + }, + filterText: "timing", + insertText: 'timing(${1|"sonic","link","homer","snorlax"|})', + insertTextFormat: 2, + kind: 3, + label: "timing", + labelDetails: { + detail: "($mode)", + }, + sortText: undefined, + tags: [], + }, + ], + }); +}); diff --git a/packages/language-services/src/features/__tests__/do-complete.test.ts b/packages/language-services/src/features/__tests__/do-complete.test.ts new file mode 100644 index 00000000..980acbf5 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-complete.test.ts @@ -0,0 +1,552 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); + ls.configure({}); // Reset any configuration to default +}); + +test("should not suggest mixin or placeholder as a property value", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "@function compare($a: 1, $b) {}", + "%placeholder { color: blue; }", + ".a { color: ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 12)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should not suggest mixin or placeholder as a variable value", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "@function compare($a: 1, $b) {}", + "%placeholder { color: blue; }", + "$my_color: ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 11)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should not suggest function, variable or placeholder after an @include", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "@function compare($a: 1, $b) {}", + "%placeholder { color: blue; }", + ".a { @include ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 14)); + + assert.ok(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); +}); + +test("should not suggest function, variable or mixin after an @extend", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "@function compare($a: 1, $b) {}", + "%placeholder { color: blue; }", + ".a { @extend ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 14)); + + assert.ok(items.find((item) => item.label === "%placeholder")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); +}); + +test("should not suggest mixin or placeholder in string interpolation", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "@function compare($a: 1, $b) {}", + "%placeholder { color: blue; }", + '$interpolation: "/some/#{', + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 25)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest variable in @return", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(3, 40)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); // allow for recursion + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function in @return", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(3, 39)); + + assert.ok(items.find((item) => item.label === "compare")); // allow for recursion + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest variable in @if", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@if $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 5)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function in @if", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@if ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 4)); + + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest variable in @else if", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@if $name {", + "} @else if $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(5, 12)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function in @else if", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@if $name {", + "} @else if ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(5, 12)); + + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should not suggest anything for @each before in", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@each ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 6)); + + assert.equal(items.length, 0); +}); + +test("should suggest variable in for @each $foo in", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@each $foo in $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 15)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function in for @each $foo in", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@each $foo in ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 15)); + + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should not suggest anything in @for before from", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 5)); + + assert.equal(items.length, 0); +}); + +test("should suggest variable in @for $i from ", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for $i from $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 15)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function in @for $i from ", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for $i from ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 15)); + + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest variable @for $i from 1 to ", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for $i from 1 to $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 19)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function @for $i from 1 to ", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for $i from 1 to ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 23)); + + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest variable in @for $i from 1 through ", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for $i from 1 through $", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 24)); + + assert.ok(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); + +test("should suggest function in @for $i from 1 through ", async () => { + ls.configure({ + completionSettings: { + suggestAllFromOpenDocument: true, + suggestFromUseOnly: false, + }, + }); + + const one = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "%placeholder { color: blue; }", + "@function compare($a: 1, $b) { @return $a * $b; }", + "@for $i from 1 through ", + ]); + + ls.parseStylesheet(one); + + const { items } = await ls.doComplete(one, Position.create(4, 24)); + + assert.ok(items.find((item) => item.label === "compare")); + assert.isUndefined(items.find((item) => item.label === "$name")); + assert.isUndefined(items.find((item) => item.label === "mixin")); + assert.isUndefined(items.find((item) => item.label === "%placeholder")); +}); diff --git a/packages/language-services/src/features/__tests__/do-diagnostics-deprecation.test.ts b/packages/language-services/src/features/__tests__/do-diagnostics-deprecation.test.ts new file mode 100644 index 00000000..d0a60680 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-diagnostics-deprecation.test.ts @@ -0,0 +1,288 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { + DiagnosticSeverity, + DiagnosticTag, +} from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("reports a deprecated variable declared in the same document", async () => { + const document = fileSystemProvider.createDocument([ + "/// @deprecated", + "$a: 1;", + ".a { content: $a; }", + ]); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "$a is deprecated", + range: { + start: { + line: 2, + character: 14, + }, + end: { + line: 2, + character: 16, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("includes the deprecation message if one is given", async () => { + const document = fileSystemProvider.createDocument([ + "/// @deprecated Use something else", + "$a: 1;", + ".a { content: $a; }", + ]); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "Use something else", + range: { + start: { + line: 2, + character: 14, + }, + end: { + line: 2, + character: 16, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("reports a deprecated function declared in the same document", async () => { + const document = fileSystemProvider.createDocument([ + "/// @deprecated", + "@function old-function() {", + " @return 1;", + "}", + ".a { content: old-function(); }", + ]); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "old-function is deprecated", + range: { + start: { + line: 4, + character: 14, + }, + end: { + line: 4, + character: 26, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("reports a deprecated mixin declared in the same document", async () => { + const document = fileSystemProvider.createDocument([ + "/// @deprecated", + "@mixin old-mixin {", + " content: 'mixin';", + "}", + ".a { @include old-mixin(); }", + ]); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "old-mixin is deprecated", + range: { + start: { + line: 4, + character: 14, + }, + end: { + line: 4, + character: 23, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("reports a deprecated variable with prefix", async () => { + const variables = fileSystemProvider.createDocument( + ["/// @deprecated", "$old-a: 1;"], + { uri: "variables.scss" }, + ); + const forward = fileSystemProvider.createDocument( + "@forward './variables' as var-*;", + { uri: "namespace.scss" }, + ); + const document = fileSystemProvider.createDocument([ + "@use 'namespace' as ns;", + ".foo {", + " color: ns.$var-old-a;", + "}", + ]); + + ls.parseStylesheet(variables); + ls.parseStylesheet(forward); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "$old-a is deprecated", + range: { + start: { + line: 2, + character: 12, + }, + end: { + line: 2, + character: 22, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("reports a deprecated function with prefix", async () => { + const functions = fileSystemProvider.createDocument( + ["/// @deprecated", "@function old-function() { @return 1; }"], + { uri: "functions.scss" }, + ); + const forward = fileSystemProvider.createDocument( + "@forward './functions' as fun-*;", + { uri: "namespace.scss" }, + ); + const document = fileSystemProvider.createDocument([ + "@use 'namespace' as ns;", + ".foo {", + " line-height: ns.fun-old-function();", + "}", + ]); + + ls.parseStylesheet(functions); + ls.parseStylesheet(forward); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "old-function is deprecated", + range: { + start: { + line: 2, + character: 18, + }, + end: { + line: 2, + character: 34, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("reports a deprecated mixin with prefix", async () => { + const mixins = fileSystemProvider.createDocument( + ["/// @deprecated", "@mixin old-mixin { content: 'mixin'; }"], + { uri: "mixins.scss" }, + ); + const forward = fileSystemProvider.createDocument( + "@forward './mixins' as mix-*;", + { uri: "namespace.scss" }, + ); + const document = fileSystemProvider.createDocument([ + "@use 'namespace' as ns;", + ".foo {", + " @include ns.mix-old-mixin;", + "}", + ]); + + ls.parseStylesheet(mixins); + ls.parseStylesheet(forward); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "old-mixin is deprecated", + range: { + start: { + line: 2, + character: 14, + }, + end: { + line: 2, + character: 27, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); + +test("reports a deprecated placeholder", async () => { + const document = fileSystemProvider.createDocument([ + "/// @deprecated Use something else", + "%oldPlaceholder {", + " content: 'placeholder';", + "}", + ".a { @extend %oldPlaceholder; }", + ]); + + const result = await ls.doDiagnostics(document); + + assert.deepStrictEqual(result, [ + { + message: "Use something else", + range: { + start: { + line: 4, + character: 13, + }, + end: { + line: 4, + character: 28, + }, + }, + severity: DiagnosticSeverity.Hint, + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + }, + ]); +}); diff --git a/packages/language-services/src/features/__tests__/do-hover.test.ts b/packages/language-services/src/features/__tests__/do-hover.test.ts new file mode 100644 index 00000000..632be64f --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-hover.test.ts @@ -0,0 +1,195 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("should show hover information for symbol in the same document", async () => { + const document = fileSystemProvider.createDocument([ + "$primary: limegreen;", + ".a { color: $primary; }", + ]); + + const result = await ls.doHover(document, Position.create(1, 15)); + assert.isNotNull(result, "Expected to find a hover result for $primary"); + assert.match(JSON.stringify(result), /\$primary/); +}); + +test("should show hover information for symbol in a different document via @import", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument(".a { color: $primary; }", { + uri: "two.scss", + }); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = await ls.doHover(two, Position.create(0, 15)); + assert.isNotNull(result, "Expected to find a hover result for $primary"); + assert.match(JSON.stringify(result), /\$primary/); +}); + +test("should show hover information for symbol in a different document via @use", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one";', ".a { color: one.$primary; }"], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = await ls.doHover(two, Position.create(1, 19)); + assert.isNotNull(result, "Expected to find a hover result for $primary"); + assert.match(JSON.stringify(result), /\$primary/); +}); + +test("should show hover information for symbol in a different document via @use with alias", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one" as o;', ".a { color: o.$primary; }"], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = await ls.doHover(two, Position.create(1, 17)); + assert.isNotNull(result, "Expected to find a hover result for $primary"); + assert.match(JSON.stringify(result), /\$primary/); +}); + +test("should show hover information for symbol in a different document via @use with wildcard alias", async () => { + const one = fileSystemProvider.createDocument("$primary: limegreen;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "./one" as *;', ".a { color: $primary; }"], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = await ls.doHover(two, Position.create(1, 17)); + assert.isNotNull(result, "Expected to find a hover result for $primary"); + assert.match(JSON.stringify(result), /\$primary/); +}); + +test("should show hover information for symbol prefixed via @forward", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument('@forward "./one" as foo-*;', { + uri: "two.scss", + }); + const three = fileSystemProvider.createDocument([ + '@use "./two";', + ".a { content: two.foo-make(1); }", + ".a { @include two.foo-mixin(); }", + ".a { content: two.$foo-a; }", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const result = await ls.doHover(three, Position.create(1, 20)); + assert.isNotNull(result, "Expected to find a hover result for foo-make"); + assert.match(JSON.stringify(result), /foo-make/); +}); + +test("should show hover information for mixin", async () => { + const document = fileSystemProvider.createDocument([ + "@mixin primary() { color: $primary; }", + ".a { @include primary; }", + ]); + + const result = await ls.doHover(document, Position.create(1, 17)); + assert.isNotNull(result, "Expected to find a hover result for primary"); + assert.match(JSON.stringify(result), /primary/); +}); + +test("should show hover information for function", async () => { + const document = fileSystemProvider.createDocument([ + "@function getprimary() { @return limegreen; }", + ".a { color: getprimary(); }", + ]); + + const result = await ls.doHover(document, Position.create(1, 17)); + assert.isNotNull(result, "Expected to find a hover result for getprimary"); + assert.match(JSON.stringify(result), /getprimary/); +}); + +test("should show hover information for placeholder", async () => { + const document = fileSystemProvider.createDocument([ + "%alert { color: limegreen; }", + ".a { @extend %alert; }", + ]); + + const result = await ls.doHover(document, Position.create(1, 17)); + assert.isNotNull(result, "Expected to find a hover result for primary"); + assert.match(JSON.stringify(result), /alert/); +}); + +test("should show hover information for Sassdoc annotation", async () => { + const document = fileSystemProvider.createDocument([ + "$a: 1;", + "/// Some wise words", + "/// @type String", + '$documented-variable: "value";', + ]); + + const result = await ls.doHover(document, Position.create(2, 8)); + assert.isNotNull(result, "Expected to find a hover result for @type"); + assert.match(JSON.stringify(result), /@type/); +}); + +test("should show hover information for Sassdoc annotation at the start of the document", async () => { + const document = fileSystemProvider.createDocument([ + "/// Some wise words", + "/// @type String", + '$documented-variable: "value";', + ]); + + const result = await ls.doHover(document, Position.create(1, 8)); + assert.isNotNull(result, "Expected to find a hover result for @type"); + assert.match(JSON.stringify(result), /@type/); +}); + +test("should show expected hover information for Sassdoc in the case of more than one", async () => { + const document = fileSystemProvider.createDocument([ + "/// Some wise words", + "/// @type String", + "/// @author wkillerud", + '$documented-variable: "value";', + ]); + + const result = await ls.doHover(document, Position.create(2, 8)); + assert.isNotNull(result, "Expected to find a hover result for @author"); + assert.match(JSON.stringify(result), /@author/); +}); diff --git a/packages/language-services/src/features/__tests__/do-rename-perform.test.ts b/packages/language-services/src/features/__tests__/do-rename-perform.test.ts new file mode 100644 index 00000000..62635b58 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-rename-perform.test.ts @@ -0,0 +1,212 @@ +import { assert, test, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position, Range } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("rename variable", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "ki";', ".a::after { content: ki.$day; }"], + { + uri: "helen.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const position = Position.create(1, 26); + const preparation = await ls.prepareRename(two, position); + + assert.isNotNull(preparation); + + const edits = await ls.doRename( + two, + // @ts-expect-error the range is there + (preparation.range as Range).start, + "gato", + ); + + assert.isNotNull(edits); + + const changes = Object.values(edits!.changes!); + assert.equal(changes.length, 2); + + const [ki, helen] = changes; + assert.deepStrictEqual(ki, [ + { + newText: "gato", + // range to be replaced + range: { + start: { + line: 0, + character: 1, + }, + end: { + line: 0, + character: 4, + }, + }, + }, + ]); + assert.deepStrictEqual(helen, [ + { + newText: "gato", + // range to be replaced + range: { + start: { + line: 1, + character: 25, + }, + end: { + line: 1, + character: 28, + }, + }, + }, + ]); +}); + +test("rename prefixed variable", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument('@forward "ki" as ki-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "dev";', ".a::after { content: dev.$ki-day; }"], + { + uri: "helen.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const preparation = await ls.prepareRename(three, Position.create(1, 30)); + + assert.isNotNull(preparation); + + const edits = await ls.doRename( + three, + // @ts-expect-error the range is there + (preparation.range as Range).start, + "gato", + ); + + assert.isNotNull(edits); + + const changes = Object.values(edits!.changes!); + assert.equal(changes.length, 2); + + const [ki, helen] = changes; + assert.deepStrictEqual(ki, [ + { + newText: "gato", + // range to be replaced + range: { + start: { + line: 0, + character: 1, + }, + end: { + line: 0, + character: 4, + }, + }, + }, + ]); + assert.deepStrictEqual(helen, [ + { + newText: "gato", + // range to be replaced + range: { + start: { + line: 1, + character: 29, + }, + end: { + line: 1, + character: 32, + }, + }, + }, + ]); +}); + +test("rename placeholder", async () => { + const one = fileSystemProvider.createDocument("%alert { color: blue; }", { + uri: "place.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "place";', ".a { @extend %alert; }"], + { + uri: "first.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const preparation = await ls.prepareRename(two, Position.create(1, 15)); + + const edits = await ls.doRename( + two, + // @ts-expect-error the range is there + (preparation.range as Range).start, + "gato", + ); + + assert.isNotNull(edits); + + const changes = Object.values(edits!.changes!); + assert.equal(changes.length, 2); + + const [ki, helen] = changes; + assert.deepStrictEqual(ki, [ + { + newText: "gato", + // range to be replaced + range: { + start: { + line: 0, + character: 1, + }, + end: { + line: 0, + character: 6, + }, + }, + }, + ]); + assert.deepStrictEqual(helen, [ + { + newText: "gato", + // range to be replaced + range: { + start: { + line: 1, + character: 14, + }, + end: { + line: 1, + character: 19, + }, + }, + }, + ]); +}); diff --git a/packages/language-services/src/features/__tests__/do-rename-prepare.test.ts b/packages/language-services/src/features/__tests__/do-rename-prepare.test.ts new file mode 100644 index 00000000..0b82f76d --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-rename-prepare.test.ts @@ -0,0 +1,111 @@ +import { assert, test, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("excludes the $ of a variable from the renaming", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "ki";', ".a::after { content: ki.$day; }"], + { + uri: "helen.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const preparation = await ls.prepareRename(two, Position.create(1, 26)); + + assert.deepStrictEqual(preparation, { + placeholder: "day", + range: { + start: { + line: 1, + character: 25, + }, + end: { + line: 1, + character: 28, + }, + }, + }); +}); + +test("excludes the % of a placeholder from the renaming", async () => { + const one = fileSystemProvider.createDocument("%alert { color: blue; }", { + uri: "place.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "place";', ".a { @extend %alert; }"], + { + uri: "first.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const preparation = await ls.prepareRename(two, Position.create(1, 15)); + + assert.deepStrictEqual(preparation, { + placeholder: "alert", + range: { + start: { + line: 1, + character: 14, + }, + end: { + line: 1, + character: 19, + }, + }, + }); +}); + +test("excludes any forward prefix from the renaming, only including the base symbol name that is the same across the workspace", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument('@forward "ki" as ki-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "dev";', ".a::after { content: dev.$ki-day; }"], + { + uri: "helen.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const preparation = await ls.prepareRename(three, Position.create(1, 30)); + + assert.deepStrictEqual(preparation, { + placeholder: "day", + range: { + start: { + line: 1, + character: 29, + }, + end: { + line: 1, + character: 32, + }, + }, + }); +}); diff --git a/packages/language-services/src/features/__tests__/do-signature-help.test.ts b/packages/language-services/src/features/__tests__/do-signature-help.test.ts new file mode 100644 index 00000000..09efaa75 --- /dev/null +++ b/packages/language-services/src/features/__tests__/do-signature-help.test.ts @@ -0,0 +1,497 @@ +import { assert, test, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); + + const stuff = fileSystemProvider.createDocument( + [ + "@mixin one() { @content; }", + "@mixin two($a, $b) { @content; }", + "@function make() { @return 1; }", + "@function one($a, $b, $c) { @return 1; }", + "@function two($d, $e) { @return 1; }", + ], + { uri: "stuff.scss" }, + ); + + ls.parseStylesheet(stuff); +}); + +test("signature help for a parameterless mixin", async () => { + const document = fileSystemProvider.createDocument("@include one(", { + uri: "things.scss", + }); + const result = await ls.doSignatureHelp(document, Position.create(0, 13)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "one()", + parameters: [], + }, + ], + activeParameter: 0, + activeSignature: 0, + }); +}); + +test("signature help for a parameterless function", async () => { + const document = fileSystemProvider.createDocument(".a { content: make()", { + uri: "things.scss", + }); + const result = await ls.doSignatureHelp(document, Position.create(0, 19)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "make()", + parameters: [], + }, + ], + activeParameter: 0, + activeSignature: 0, + }); +}); + +test("signature help for a mixin closed without parameters", async () => { + const document = fileSystemProvider.createDocument("@include two()", { + uri: "things.scss", + }); + const result = await ls.doSignatureHelp(document, Position.create(0, 13)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($a, $b)", + parameters: [ + { + documentation: undefined, + label: "$a", + }, + { + documentation: undefined, + label: "$b", + }, + ], + }, + ], + activeParameter: 0, + activeSignature: 0, + }); +}); + +test("signature help when one of two mixin parameters are filled in", async () => { + const document = fileSystemProvider.createDocument("@include two($a: 1,)", { + uri: "things.scss", + }); + const result = await ls.doSignatureHelp(document, Position.create(0, 19)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($a, $b)", + parameters: [ + { + documentation: undefined, + label: "$a", + }, + { + documentation: undefined, + label: "$b", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); +}); + +test("signature help for module mixin", async () => { + const document = fileSystemProvider.createDocument( + ['@use "stuff";', "@include stuff.two("], + { + uri: "things.scss", + }, + ); + const result = await ls.doSignatureHelp(document, Position.create(1, 19)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($a, $b)", + parameters: [ + { + documentation: undefined, + label: "$a", + }, + { + documentation: undefined, + label: "$b", + }, + ], + }, + ], + activeParameter: 0, + activeSignature: 0, + }); +}); + +test("signature help for module mixin with parameters", async () => { + const document = fileSystemProvider.createDocument( + ['@use "stuff";', "@include stuff.two(1,)"], + { + uri: "things.scss", + }, + ); + const result = await ls.doSignatureHelp(document, Position.create(1, 21)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($a, $b)", + parameters: [ + { + documentation: undefined, + label: "$a", + }, + { + documentation: undefined, + label: "$b", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); +}); + +test("signature help for module mixin behind prefix", async () => { + const forward = fileSystemProvider.createDocument( + ['@forward "stuff" as things-*;'], + { + uri: "things.scss", + }, + ); + ls.parseStylesheet(forward); + + const document = fileSystemProvider.createDocument( + ['@use "things" as t;', "@include t.things-two()"], + { + uri: "other-things.scss", + }, + ); + + const result = await ls.doSignatureHelp(document, Position.create(1, 22)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "things-two($a, $b)", + parameters: [ + { + documentation: undefined, + label: "$a", + }, + { + documentation: undefined, + label: "$b", + }, + ], + }, + ], + activeParameter: 0, + activeSignature: 0, + }); +}); + +test("signature help when one of two function parameters are filled in", async () => { + const document = fileSystemProvider.createDocument( + ['@use "stuff";', ".a { content: stuff.two(1,)"], + { + uri: "things.scss", + }, + ); + const result = await ls.doSignatureHelp(document, Position.create(1, 26)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($d, $e)", + parameters: [ + { + documentation: undefined, + label: "$d", + }, + { + documentation: undefined, + label: "$e", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); +}); + +test("signature help for module function", async () => { + const document = fileSystemProvider.createDocument(".a { content: two(1,)", { + uri: "things.scss", + }); + const result = await ls.doSignatureHelp(document, Position.create(0, 20)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($d, $e)", + parameters: [ + { + documentation: undefined, + label: "$d", + }, + { + documentation: undefined, + label: "$e", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); +}); + +test("signature help when given more parameters than are supported", async () => { + const document = fileSystemProvider.createDocument("@include two(1,2,3)", { + uri: "things.scss", + }); + const result = await ls.doSignatureHelp(document, Position.create(0, 18)); + + assert.deepStrictEqual(result, { + signatures: [], + activeParameter: 0, + activeSignature: 0, + }); +}); + +test("is not confused by using a function as a parameter", async () => { + const document = fileSystemProvider.createDocument( + ".a { content: two(rgba(0,0,0,.0001),)", + { + uri: "things.scss", + }, + ); + const result = await ls.doSignatureHelp(document, Position.create(0, 36)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($d, $e)", + parameters: [ + { + documentation: undefined, + label: "$d", + }, + { + documentation: undefined, + label: "$e", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); +}); + +test("does not get the functions mixed when editing a function as a parameter", async () => { + const document = fileSystemProvider.createDocument( + ".a { content: two(rgba(0,0,0,", + { + uri: "things.scss", + }, + ); + const result = await ls.doSignatureHelp(document, Position.create(0, 29)); + + assert.deepStrictEqual(result, { + signatures: [], + activeParameter: 3, + activeSignature: 0, + }); +}); + +test("signature help inside string interpolation", async () => { + const document = fileSystemProvider.createDocument( + ".a { content: #{two(1,)}", + { + uri: "things.scss", + }, + ); + const result = await ls.doSignatureHelp(document, Position.create(0, 22)); + + assert.deepStrictEqual(result, { + signatures: [ + { + documentation: { + kind: "markdown", + value: "", + }, + label: "two($d, $e)", + parameters: [ + { + documentation: undefined, + label: "$d", + }, + { + documentation: undefined, + label: "$e", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); +}); + +test("provides signature help for sass built-ins", async () => { + const document = fileSystemProvider.createDocument( + [ + "@use 'sass:math' as magic;", + ".foo {", + " font-size: magic.clamp();", + " font-size: magic.clamp(1,);", + " font-size: magic.clamp(1,2,);", + "}", + ], + { uri: "builtins.scss" }, + ); + + const first = await ls.doSignatureHelp(document, Position.create(2, 25)); + const second = await ls.doSignatureHelp(document, Position.create(3, 27)); + const third = await ls.doSignatureHelp(document, Position.create(4, 29)); + + assert.deepStrictEqual(first, { + signatures: [ + { + documentation: { + kind: "markdown", + value: + "Restricts $number to the range between `$min` and `$max`. If `$number` is less than `$min` this returns `$min`, and if it's greater than `$max` this returns `$max`.\n\n[Sass reference](https://sass-lang.com/documentation/modules/math#clamp)", + }, + label: "clamp($min, $number, $max)", + parameters: [ + { + label: "$min", + }, + { + label: "$number", + }, + { + label: "$max", + }, + ], + }, + ], + activeParameter: 0, + activeSignature: 0, + }); + assert.deepStrictEqual(second, { + signatures: [ + { + documentation: { + kind: "markdown", + value: + "Restricts $number to the range between `$min` and `$max`. If `$number` is less than `$min` this returns `$min`, and if it's greater than `$max` this returns `$max`.\n\n[Sass reference](https://sass-lang.com/documentation/modules/math#clamp)", + }, + label: "clamp($min, $number, $max)", + parameters: [ + { + label: "$min", + }, + { + label: "$number", + }, + { + label: "$max", + }, + ], + }, + ], + activeParameter: 1, + activeSignature: 0, + }); + assert.deepStrictEqual(third, { + signatures: [ + { + documentation: { + kind: "markdown", + value: + "Restricts $number to the range between `$min` and `$max`. If `$number` is less than `$min` this returns `$min`, and if it's greater than `$max` this returns `$max`.\n\n[Sass reference](https://sass-lang.com/documentation/modules/math#clamp)", + }, + label: "clamp($min, $number, $max)", + parameters: [ + { + label: "$min", + }, + { + label: "$number", + }, + { + label: "$max", + }, + ], + }, + ], + activeParameter: 2, + activeSignature: 0, + }); +}); diff --git a/packages/language-services/src/features/__tests__/find-colors.test.ts b/packages/language-services/src/features/__tests__/find-colors.test.ts new file mode 100644 index 00000000..92e00144 --- /dev/null +++ b/packages/language-services/src/features/__tests__/find-colors.test.ts @@ -0,0 +1,44 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("should find color information from the variable declaration", async () => { + const one = fileSystemProvider.createDocument("$a: red;", { + uri: "one.scss", + }); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.$a; }", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const [colorInformation] = await ls.findColors(two); + assert.deepStrictEqual(colorInformation, { + color: { + alpha: 1, + blue: 0, + green: 0, + red: 1, + }, + range: { + end: { + character: 20, + line: 1, + }, + start: { + character: 18, + line: 1, + }, + }, + }); +}); diff --git a/packages/language-services/src/features/__tests__/find-definition.test.ts b/packages/language-services/src/features/__tests__/find-definition.test.ts new file mode 100644 index 00000000..bf8d798e --- /dev/null +++ b/packages/language-services/src/features/__tests__/find-definition.test.ts @@ -0,0 +1,285 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("should find variable definition", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument(".a { content: $a; }"); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const variablePosition = Position.create(0, 14); + const location = await ls.findDefinition(two, variablePosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 2, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }); +}); + +test("should find mixin definition", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument(".a { @include mixin(); }"); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const mixinPosition = Position.create(0, 16); + const location = await ls.findDefinition(two, mixinPosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 12, + line: 1, + }, + start: { + character: 7, + line: 1, + }, + }); +}); + +test("should find function definition", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument(".a { content: make(1); }"); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const functionPosition = Position.create(0, 16); + const location = await ls.findDefinition(two, functionPosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 14, + line: 2, + }, + start: { + character: 10, + line: 2, + }, + }); +}); + +test("should find variable definition via the module link", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.$a; }", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const variablePosition = Position.create(1, 19); + const location = await ls.findDefinition(two, variablePosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 2, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }); +}); + +test("should find mixin definition via the module link", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { @include one.mixin(); }", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const mixinPosition = Position.create(1, 19); + const location = await ls.findDefinition(two, mixinPosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 12, + line: 1, + }, + start: { + character: 7, + line: 1, + }, + }); +}); + +test("should find function definition via the module link", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument([ + '@use "./one";', + ".a { content: one.make(1); }", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const functionPosition = Position.create(1, 20); + const location = await ls.findDefinition(two, functionPosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 14, + line: 2, + }, + start: { + character: 10, + line: 2, + }, + }); +}); + +test("should find placeholder definition", async () => { + const one = fileSystemProvider.createDocument( + "%alert { background-color: red }", + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument(".a { @extend %alert; }"); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const placeholderPosition = Position.create(0, 16); + const location = await ls.findDefinition(two, placeholderPosition); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 6, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }); +}); + +test("should find prefixed symbols", async () => { + const one = fileSystemProvider.createDocument( + ["$a: 1;", "@mixin mixin() { @content; }", "@function make() { @return; }"], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument('@forward "./one" as foo-*;', { + uri: "two.scss", + }); + const three = fileSystemProvider.createDocument([ + '@use "./two";', + ".a { content: two.foo-make(1); }", + ".a { @include two.foo-mixin(); }", + ".a { content: two.$foo-a; }", + ]); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + let position = Position.create(1, 20); + let location = await ls.findDefinition(three, position); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 14, + line: 2, + }, + start: { + character: 10, + line: 2, + }, + }); + + position = Position.create(2, 20); + location = await ls.findDefinition(three, position); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 12, + line: 1, + }, + start: { + character: 7, + line: 1, + }, + }); + + position = Position.create(3, 21); + location = await ls.findDefinition(three, position); + + assert.isNotNull(location); + assert.match(location!.uri, /one\.scss$/); + assert.deepStrictEqual(location!.range, { + end: { + character: 2, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }); +}); diff --git a/packages/language-services/src/features/__tests__/find-document-links.test.ts b/packages/language-services/src/features/__tests__/find-document-links.test.ts new file mode 100644 index 00000000..b178ce34 --- /dev/null +++ b/packages/language-services/src/features/__tests__/find-document-links.test.ts @@ -0,0 +1,66 @@ +import { test, assert } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { NodeType } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +test("should return links", async () => { + fileSystemProvider.createDocument(["$var: 1px;"], { + uri: "variables.scss", + }); + fileSystemProvider.createDocument(["$b: #000;"], { + uri: "corners.scss", + }); + fileSystemProvider.createDocument(["$tr: 2px;"], { + uri: "colors.scss", + }); + + const document = fileSystemProvider.createDocument([ + '@use "corners" as *;', + '@use "variables" as vars;', + '@forward "colors" as color-* hide $varslingsfarger, varslingsfarge;', + '@forward "./foo" as foo-* hide $private;', + ]); + + const links = await ls.findDocumentLinks(document); + + // Uses + const uses = links.filter((link) => link.type === NodeType.Use); + assert.strictEqual(uses.length, 2, "expected to find two uses"); + assert.strictEqual(uses[0]?.namespace, undefined); + assert.strictEqual(uses[0]?.as, "*"); + assert.strictEqual(uses[1]?.namespace, "vars"); + + // Forward + const forwards = links.filter((link) => link.type === NodeType.Forward); + assert.strictEqual(forwards.length, 2, "expected to find two forward"); + assert.strictEqual(forwards[0]?.as, "color-"); + assert.deepStrictEqual(forwards[0]?.hide, [ + "$varslingsfarger", + "varslingsfarge", + ]); + assert.strictEqual(forwards[1]?.as, "foo-"); + assert.deepStrictEqual(forwards[1]?.hide, ["$private"]); +}); + +test("should return relative links", async () => { + fileSystemProvider.createDocument(["$var: 1px;"], { + uri: "upper.scss", + }); + fileSystemProvider.createDocument(["$b: #000;"], { + uri: "middle/middle.scss", + }); + fileSystemProvider.createDocument(["$tr: 2px;"], { + uri: "middle/lower/lower.scss", + }); + + const document = fileSystemProvider.createDocument( + ['@use "../upper";', '@use "./middle";', '@use "./lower/lower";'], + { uri: "middle/main.scss" }, + ); + + const links = await ls.findDocumentLinks(document); + assert.strictEqual(links.length, 3, "expected to find three uses"); +}); diff --git a/packages/language-services/src/features/__tests__/find-references.test.ts b/packages/language-services/src/features/__tests__/find-references.test.ts new file mode 100644 index 00000000..918fad4d --- /dev/null +++ b/packages/language-services/src/features/__tests__/find-references.test.ts @@ -0,0 +1,824 @@ +import { assert, test, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { Position } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("finds variable references", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "ki";', ".a::after { content: ki.$day; }"], + { + uri: "helen.scss", + }, + ); + const three = fileSystemProvider.createDocument( + [ + '@use "ki";', + ".a::before {", + " // Here it comes!", + " content: ki.$day;", + "}", + ], + { + uri: "gato.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const references = await ls.findReferences(two, Position.create(1, 25)); + + assert.equal(references.length, 3); + + const [ki, helen, gato] = references; + assert.match(ki.uri, /ki\.scss$/); + assert.match(helen.uri, /helen\.scss$/); + assert.match(gato.uri, /gato\.scss$/); + + assert.deepStrictEqual(ki.range, { + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 4, + }, + }); + + assert.deepStrictEqual(helen.range, { + start: { + line: 1, + character: 24, + }, + end: { + line: 1, + character: 28, + }, + }); + + assert.deepStrictEqual(gato.range, { + start: { + line: 3, + character: 13, + }, + end: { + line: 3, + character: 17, + }, + }); +}); + +test("exclude declaration if the user requests so", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "ki";', ".a::after { content: ki.$day; }"], + { + uri: "helen.scss", + }, + ); + const three = fileSystemProvider.createDocument( + [ + '@use "ki";', + ".a::before {", + " // Here it comes!", + " content: ki.$day;", + "}", + ], + { + uri: "gato.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const references = await ls.findReferences(two, Position.create(1, 25), { + includeDeclaration: false, + }); + + assert.equal(references.length, 2); + + const [helen, gato] = references; + + assert.match(helen.uri, /helen\.scss$/); + assert.match(gato.uri, /gato\.scss$/); +}); + +test("finds variable with @forward prefix", async () => { + const one = fileSystemProvider.createDocument('$day: "monday";', { + uri: "ki.scss", + }); + const two = fileSystemProvider.createDocument('@forward "ki" as ki-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "dev";', ".a::after { content: dev.$ki-day; }"], + { + uri: "helen.scss", + }, + ); + const four = fileSystemProvider.createDocument( + [ + '@use "ki";', + ".a::before {", + " // Here it comes!", + " content: ki.$day;", + "}", + ], + { + uri: "gato.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + ls.parseStylesheet(four); + + const references = await ls.findReferences(four, Position.create(3, 15)); + + assert.equal(references.length, 3); + + const [ki, helen, gato] = references; + assert.match(ki.uri, /ki\.scss$/); + assert.match(helen.uri, /helen\.scss$/); + assert.match(gato.uri, /gato\.scss$/); + + assert.deepStrictEqual(ki.range, { + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 4, + }, + }); + + assert.deepStrictEqual(helen.range, { + start: { + line: 1, + character: 25, + }, + end: { + line: 1, + character: 32, + }, + }); + + assert.deepStrictEqual(gato.range, { + start: { + line: 3, + character: 13, + }, + end: { + line: 3, + character: 17, + }, + }); +}); + +test("finds variables with @forward prefix when used as a function parameter", async () => { + const one = fileSystemProvider.createDocument( + [ + "@function hello($var) { @return $var; }", + '$name: "there";', + '$reply: "general";', + ], + { + uri: "fun.scss", + }, + ); + const two = fileSystemProvider.createDocument('@forward "fun" as fun-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + [ + '@use "dev";', + "$_b: 1;", + ".a {", + " // Here it comes!", + " content: dev.fun-hello(dev.$fun-name, $_b);", + "}", + ], + { + uri: "usage.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const references = await ls.findReferences(three, Position.create(4, 34)); + + assert.equal(references.length, 2); + + const [fun, usage] = references; + assert.match(fun.uri, /fun\.scss$/); + assert.match(usage.uri, /usage\.scss$/); + + assert.deepStrictEqual(fun.range, { + start: { + line: 1, + character: 0, + }, + end: { + line: 1, + character: 5, + }, + }); + assert.deepStrictEqual(usage.range, { + start: { + line: 4, + character: 28, + }, + end: { + line: 4, + character: 37, + }, + }); +}); + +test("finds variable used in visibility modifier", async () => { + const one = fileSystemProvider.createDocument(["$secret: 1;"], { + uri: "var.scss", + }); + const two = fileSystemProvider.createDocument( + ['@forward "var" as var-* hide $secret;'], + { + uri: "dev.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const references = await ls.findReferences(one, Position.create(0, 2)); + + assert.equal(references.length, 2); + + const [dec, hide] = references; + assert.match(dec.uri, /var\.scss$/); + assert.match(hide.uri, /dev\.scss$/); + + assert.deepStrictEqual(dec.range, { + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 7, + }, + }); + assert.deepStrictEqual(hide.range, { + start: { + line: 0, + character: 29, + }, + end: { + line: 0, + character: 36, + }, + }); +}); + +test("finds function with @forward prefix", async () => { + const one = fileSystemProvider.createDocument( + "@function hello() { @return 1; }", + { + uri: "func.scss", + }, + ); + const two = fileSystemProvider.createDocument('@forward "func" as fun-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "dev";', ".a {", " line-height: dev.fun-hello();", "}"], + { + uri: "one.scss", + }, + ); + const four = fileSystemProvider.createDocument( + [ + '@use "func";', + ".a {", + " // Here it comes!", + " line-height: func.hello();", + "}", + ], + { + uri: "two.scss", + }, + ); + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + ls.parseStylesheet(four); + + const references = await ls.findReferences(four, Position.create(3, 22), { + includeDeclaration: true, + }); + + assert.equal(references.length, 3); + + const [func, o, t] = references; + assert.match(func.uri, /func\.scss$/); + assert.match(o.uri, /one\.scss$/); + assert.match(t.uri, /two\.scss$/); + + assert.deepStrictEqual(func.range, { + start: { + line: 0, + character: 10, + }, + end: { + line: 0, + character: 15, + }, + }); + assert.deepStrictEqual(o.range, { + start: { + line: 2, + character: 18, + }, + end: { + line: 2, + character: 27, + }, + }); + assert.deepStrictEqual(t.range, { + start: { + line: 3, + character: 19, + }, + end: { + line: 3, + character: 24, + }, + }); +}); + +test("finds function used in visibility modifier", async () => { + const one = fileSystemProvider.createDocument( + "@function hello() { @return 1; }", + { + uri: "func.scss", + }, + ); + const two = fileSystemProvider.createDocument( + ['@forward "func" as fun-* show hello;'], + { + uri: "dev.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const references = await ls.findReferences(one, Position.create(0, 12)); + + assert.equal(references.length, 2); + + const [dec, hide] = references; + assert.match(dec.uri, /func\.scss$/); + assert.match(hide.uri, /dev\.scss$/); + + assert.deepStrictEqual(dec.range, { + start: { + line: 0, + character: 10, + }, + end: { + line: 0, + character: 15, + }, + }); + assert.deepStrictEqual(hide.range, { + start: { + line: 0, + character: 30, + }, + end: { + line: 0, + character: 35, + }, + }); +}); + +test("finds mixins", async () => { + const one = fileSystemProvider.createDocument( + "@mixin hello() { line-height: 1; }", + { + uri: "mix.scss", + }, + ); + const two = fileSystemProvider.createDocument( + ['@use "mix";', ".a { @include mix.hello(); }"], + { + uri: "first.scss", + }, + ); + const three = fileSystemProvider.createDocument( + ['@use "mix";', ".a {", " // Here it comes!", " @include mix.hello;", "}"], + { + uri: "second.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const references = await ls.findReferences(two, Position.create(1, 20)); + + assert.equal(references.length, 3); + + const [mix, first, second] = references; + assert.match(mix.uri, /mix\.scss$/); + assert.match(first.uri, /first\.scss$/); + assert.match(second.uri, /second\.scss$/); + + assert.deepStrictEqual(mix.range, { + start: { + line: 0, + character: 7, + }, + end: { + line: 0, + character: 12, + }, + }); + assert.deepStrictEqual(first.range, { + start: { + line: 1, + character: 18, + }, + end: { + line: 1, + character: 23, + }, + }); + assert.deepStrictEqual(second.range, { + start: { + line: 3, + character: 14, + }, + end: { + line: 3, + character: 19, + }, + }); +}); + +test("finds mixins with @forward prefix", async () => { + const one = fileSystemProvider.createDocument( + "@mixin hello() { line-height: 1; }", + { + uri: "mix.scss", + }, + ); + const two = fileSystemProvider.createDocument('@forward "mix" as mix-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + ['@use "dev";', ".a { @include dev.mix-hello(); }"], + { + uri: "first.scss", + }, + ); + const four = fileSystemProvider.createDocument( + ['@use "mix";', ".a {", " // Here it comes!", " @include mix.hello();", "}"], + { + uri: "second.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + ls.parseStylesheet(four); + + const references = await ls.findReferences(three, Position.create(1, 24)); + + assert.equal(references.length, 3); + + const [mix, first, second] = references; + assert.match(mix.uri, /mix\.scss$/); + assert.match(first.uri, /first\.scss$/); + assert.match(second.uri, /second\.scss$/); + + assert.deepStrictEqual(mix.range, { + start: { + line: 0, + character: 7, + }, + end: { + line: 0, + character: 12, + }, + }); + assert.deepStrictEqual(first.range, { + start: { + line: 1, + character: 18, + }, + end: { + line: 1, + character: 27, + }, + }); + assert.deepStrictEqual(second.range, { + start: { + line: 3, + character: 14, + }, + end: { + line: 3, + character: 19, + }, + }); +}); + +test("finds mixins used in visibility modifier", async () => { + const one = fileSystemProvider.createDocument( + "@mixin hello() { @return 1; }", + { + uri: "mix.scss", + }, + ); + const two = fileSystemProvider.createDocument( + ['@forward "mix" as mix-* hide hello;'], + { + uri: "dev.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const references = await ls.findReferences(one, Position.create(0, 10)); + + assert.equal(references.length, 2); + + const [dec, hide] = references; + assert.match(dec.uri, /mix\.scss$/); + assert.match(hide.uri, /dev\.scss$/); + + assert.deepStrictEqual(dec.range, { + start: { + line: 0, + character: 7, + }, + end: { + line: 0, + character: 12, + }, + }); + assert.deepStrictEqual(hide.range, { + start: { + line: 0, + character: 29, + }, + end: { + line: 0, + character: 34, + }, + }); +}); + +test("finds references in maps", async () => { + const one = fileSystemProvider.createDocument( + ["@function hello() { @return 1; }", '$day: "monday";'], + { + uri: "fun.scss", + }, + ); + const two = fileSystemProvider.createDocument('@forward "fun" as fun-*;', { + uri: "dev.scss", + }); + const three = fileSystemProvider.createDocument( + [ + '@use "dev";', + "$map: (", + ' "gloomy": dev.$fun-day,', + ' "goodbye": dev.fun-hello(),', + ");", + ], + { + uri: "one.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const variableReferences = await ls.findReferences( + three, + Position.create(2, 21), + ); + const functionReferences = await ls.findReferences( + three, + Position.create(3, 22), + ); + + assert.equal(variableReferences.length, 2); + assert.equal(functionReferences.length, 2); + + const [varDec, varMap] = variableReferences; + + assert.match(varDec.uri, /fun\.scss$/); + assert.match(varMap.uri, /one\.scss$/); + + assert.deepStrictEqual(varDec.range, { + start: { + line: 1, + character: 0, + }, + end: { + line: 1, + character: 4, + }, + }); + assert.deepStrictEqual(varMap.range, { + start: { + line: 2, + character: 15, + }, + end: { + line: 2, + character: 23, + }, + }); + + const [funDec, funMap] = functionReferences; + assert.deepStrictEqual(funDec.range, { + start: { + line: 0, + character: 10, + }, + end: { + line: 0, + character: 15, + }, + }); + assert.deepStrictEqual(funMap.range, { + start: { + line: 3, + character: 16, + }, + end: { + line: 3, + character: 25, + }, + }); +}); + +test("finds sass built-ins", async () => { + const one = fileSystemProvider.createDocument( + [ + '@use "sass:color";', + '$_color: color.scale($color: "#1b1917", $alpha: -75%);', + ".a {", + " color: $_color;", + " transform: scale(1.1);", + "}", + ], + { + uri: "first.scss", + }, + ); + const two = fileSystemProvider.createDocument( + [ + '@use "sass:color";', + '$_other-color: color.scale($color: "#1b1917", $alpha: -75%);', + ], + { + uri: "second.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const references = await ls.findReferences(one, Position.create(1, 16)); + + assert.equal(references.length, 2); + + const [first, second] = references; + assert.match(first.uri, /first\.scss$/); + assert.match(second.uri, /second\.scss$/); + + assert.deepStrictEqual(first.range, { + start: { + line: 1, + character: 15, + }, + end: { + line: 1, + character: 20, + }, + }); + assert.deepStrictEqual(second.range, { + start: { + line: 1, + character: 21, + }, + end: { + line: 1, + character: 26, + }, + }); +}); + +test("finds placeholders", async () => { + const one = fileSystemProvider.createDocument("%alert { color: blue; }", { + uri: "place.scss", + }); + const two = fileSystemProvider.createDocument( + ['@use "place";', ".a { @extend %alert; }"], + { + uri: "first.scss", + }, + ); + const three = fileSystemProvider.createDocument( + ['@use "place";', ".a {", " // Here it comes!", " @extend %alert;", "}"], + { + uri: "second.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + ls.parseStylesheet(three); + + const references = await ls.findReferences(two, Position.create(1, 16)); + + assert.equal(references.length, 3); + + const [place, first, second] = references; + assert.match(place.uri, /place\.scss$/); + assert.match(first.uri, /first\.scss$/); + assert.match(second.uri, /second\.scss$/); + + assert.deepStrictEqual(place.range, { + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 6, + }, + }); + assert.deepStrictEqual(first.range, { + start: { + line: 1, + character: 13, + }, + end: { + line: 1, + character: 19, + }, + }); + assert.deepStrictEqual(second.range, { + start: { + line: 3, + character: 9, + }, + end: { + line: 3, + character: 15, + }, + }); +}); diff --git a/packages/language-services/src/features/__tests__/find-symbols-document.test.ts b/packages/language-services/src/features/__tests__/find-symbols-document.test.ts new file mode 100644 index 00000000..317740d1 --- /dev/null +++ b/packages/language-services/src/features/__tests__/find-symbols-document.test.ts @@ -0,0 +1,208 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { SymbolKind } from "../../language-services-types"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("should return symbols", async () => { + const document = fileSystemProvider.createDocument([ + '$name: "value";', + "@mixin mixin($a: 1, $b) {}", + "@function function($a: 1, $b) {}", + "%placeholder { color: blue; }", + ]); + + const symbols = ls.findDocumentSymbols(document); + const [variable, mixin, func, placeholder] = symbols; + + assert.deepStrictEqual(variable, { + kind: SymbolKind.Variable, + name: "$name", + range: { + end: { + character: 14, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }, + selectionRange: { + end: { + character: 5, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }, + }); + assert.deepStrictEqual(mixin, { + kind: SymbolKind.Method, + name: "mixin", + detail: "($a: 1, $b)", + range: { + end: { + character: 26, + line: 1, + }, + start: { + character: 0, + line: 1, + }, + }, + selectionRange: { + end: { + character: 12, + line: 1, + }, + start: { + character: 7, + line: 1, + }, + }, + }); + assert.deepStrictEqual(func, { + kind: SymbolKind.Function, + name: "function", + detail: "($a: 1, $b)", + range: { + end: { + character: 32, + line: 2, + }, + start: { + character: 0, + line: 2, + }, + }, + selectionRange: { + end: { + character: 18, + line: 2, + }, + start: { + character: 10, + line: 2, + }, + }, + }); + assert.deepStrictEqual(placeholder, { + kind: SymbolKind.Class, + name: "%placeholder", + range: { + end: { + character: 29, + line: 3, + }, + start: { + character: 0, + line: 3, + }, + }, + selectionRange: { + end: { + character: 12, + line: 3, + }, + start: { + character: 0, + line: 3, + }, + }, + }); +}); + +test("includes placeholder usages in a way that is distinguishable from declarations", () => { + const document = fileSystemProvider.createDocument([ + "%placeholder { color: blue; }", + ".button { @extend %placeholder; }", + ]); + + const symbols = ls.findDocumentSymbols(document); + const [declaration, buttonWithPlaceholderUsage] = symbols; + + assert.deepStrictEqual(declaration, { + kind: SymbolKind.Class, + name: "%placeholder", + range: { + end: { + character: 29, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }, + selectionRange: { + end: { + character: 12, + line: 0, + }, + start: { + character: 0, + line: 0, + }, + }, + }); + + assert.deepStrictEqual(buttonWithPlaceholderUsage, { + children: [ + { + kind: 5, + name: "%placeholder", + range: { + end: { + character: 30, + line: 1, + }, + start: { + character: 18, + line: 1, + }, + }, + selectionRange: { + end: { + character: 30, + line: 1, + }, + start: { + character: 18, + line: 1, + }, + }, + }, + ], + kind: 5, + name: ".button", + range: { + end: { + character: 33, + line: 1, + }, + start: { + character: 0, + line: 1, + }, + }, + selectionRange: { + end: { + character: 7, + line: 1, + }, + start: { + character: 0, + line: 1, + }, + }, + }); +}); diff --git a/packages/language-services/src/features/__tests__/find-symbols-workspace.test.ts b/packages/language-services/src/features/__tests__/find-symbols-workspace.test.ts new file mode 100644 index 00000000..4b34bafd --- /dev/null +++ b/packages/language-services/src/features/__tests__/find-symbols-workspace.test.ts @@ -0,0 +1,68 @@ +import { test, assert, beforeEach } from "vitest"; +import { getLanguageService } from "../../language-services"; +import { getOptions } from "../../utils/test-helpers"; + +const { fileSystemProvider, ...rest } = getOptions(); +const ls = getLanguageService({ fileSystemProvider, ...rest }); + +beforeEach(() => { + ls.clearCache(); +}); + +test("empty query returns all workspace symbols", async () => { + const one = fileSystemProvider.createDocument( + [ + "$vone: 1;", + "@mixin mone() { @content; }", + "@function fone() { @return; }", + ], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument( + [ + "$vone: 1;", + "@mixin mone() { @content; }", + "@function fone() { @return; }", + ], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = ls.findWorkspaceSymbols(""); + + assert.equal(result.length, 6); +}); + +test("returns workspace symbols matching query", async () => { + const one = fileSystemProvider.createDocument( + [ + "$vone: 1;", + "@mixin mone() { @content; }", + "@function fone() { @return; }", + ], + { uri: "one.scss" }, + ); + const two = fileSystemProvider.createDocument( + [ + "$vtwo: 1;", + "@mixin mtwo() { @content; }", + "@function ftwo() { @return; }", + ], + { + uri: "two.scss", + }, + ); + + // emulate scanner of language service which adds workspace documents to the cache + ls.parseStylesheet(one); + ls.parseStylesheet(two); + + const result = ls.findWorkspaceSymbols("two"); + + assert.equal(result.length, 3); +}); diff --git a/packages/language-server/src/features/code-actions/extract-provider.ts b/packages/language-services/src/features/code-actions.ts similarity index 71% rename from packages/language-server/src/features/code-actions/extract-provider.ts rename to packages/language-services/src/features/code-actions.ts index e2d5e4bc..f29d6286 100644 --- a/packages/language-server/src/features/code-actions/extract-provider.ts +++ b/packages/language-services/src/features/code-actions.ts @@ -1,37 +1,33 @@ -import { TextDocument } from "vscode-languageserver-textdocument"; +import { LanguageFeature } from "../language-feature"; import { CodeAction, + CodeActionContext, CodeActionKind, + LanguageServiceConfiguration, Position, Range, + TextDocument, TextDocumentEdit, TextEdit, VersionedTextDocumentIdentifier, WorkspaceEdit, -} from "vscode-languageserver-types"; -import { IEditorSettings } from "../../settings"; -import { getEOL, getLinesFromText, indentText } from "../../utils/string"; -import { CodeActionProvider } from "./types"; +} from "../language-services-types"; -export class ExtractProvider implements CodeActionProvider { - private _settings: IEditorSettings; - - constructor(settings: IEditorSettings) { - this._settings = settings; - } - - public async provideCodeActions( +export class CodeActions extends LanguageFeature { + async getCodeActions( document: TextDocument, range: Range, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + context: CodeActionContext = { diagnostics: [] }, ): Promise { if (!this.hasSelection(range)) { return []; } return [ - this.provideExtractVariableAction(document, range), - this.provideExtractMixinAction(document, range), - this.provideExtractFunctionAction(document, range), + this.getExtractVariableAction(document, range), + this.getExtractMixinAction(document, range), + this.getExtractFunctionAction(document, range), ]; } @@ -41,7 +37,7 @@ export class ExtractProvider implements CodeActionProvider { return lineDiff !== 0 || charDiff !== 0; } - private provideExtractFunctionAction( + private getExtractFunctionAction( document: TextDocument, range: Range, ): CodeAction { @@ -66,10 +62,10 @@ export class ExtractProvider implements CodeActionProvider { `${indent}${indentText( `@return ${lines .map((line, index) => - index === 0 ? line : indentText(line, this._settings), + index === 0 ? line : indentText(line, this.configuration), ) .join(eol)}`, - this._settings, + this.configuration, )}${selectedText.endsWith(";") ? "" : ";"}`, `${indent}}`, `${indent}${onlyNonWhitespace}_function()${ @@ -106,7 +102,7 @@ export class ExtractProvider implements CodeActionProvider { return action; } - private provideExtractMixinAction( + private getExtractMixinAction( document: TextDocument, range: Range, ): CodeAction { @@ -130,7 +126,10 @@ export class ExtractProvider implements CodeActionProvider { "@mixin _mixin {", ...lines.map((line, index) => line - ? indentText(index === 0 ? `${indent}${line}` : line, this._settings) + ? indentText( + index === 0 ? `${indent}${line}` : line, + this.configuration, + ) : line, ), `${indent}}`, @@ -158,7 +157,7 @@ export class ExtractProvider implements CodeActionProvider { return action; } - private provideExtractVariableAction( + private getExtractVariableAction( document: TextDocument, range: Range, ): CodeAction { @@ -211,3 +210,48 @@ export class ExtractProvider implements CodeActionProvider { return action; } } + +const reNewline = /\r\n|\r|\n/; + +function getLinesFromText(text: string): string[] { + return text.split(reNewline); +} + +const space = " "; +const tab = " "; + +function indentText( + text: string, + settings: LanguageServiceConfiguration, +): string { + if (settings.editorSettings?.insertSpaces) { + const numberOfSpaces: number = + typeof settings.editorSettings?.indentSize === "number" + ? settings.editorSettings?.indentSize + : typeof settings.editorSettings?.tabSize === "number" + ? settings.editorSettings?.tabSize + : 2; + return `${space.repeat(numberOfSpaces)}${text}`; + } + + return `${tab}${text}`; +} + +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * MIT License + */ +export function getEOL(text: string): string { + for (let i = 0; i < text.length; i++) { + const ch = text.charAt(i); + if (ch === "\r") { + if (i + 1 < text.length && text.charAt(i + 1) === "\n") { + return "\r\n"; + } + return "\r"; + } else if (ch === "\n") { + return "\n"; + } + } + return "\n"; +} diff --git a/packages/language-services/src/features/do-complete.ts b/packages/language-services/src/features/do-complete.ts new file mode 100644 index 00000000..884194a7 --- /dev/null +++ b/packages/language-services/src/features/do-complete.ts @@ -0,0 +1,1560 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import ColorDotJS from "colorjs.io"; +import { ParseResult } from "scss-sassdoc-parser"; +import { SassBuiltInModule, sassBuiltInModules } from "../facts/sass"; +import { sassDocAnnotations } from "../facts/sassdoc"; +import { LanguageFeature } from "../language-feature"; +import { + TokenType, + IToken, + NodeType, + CompletionItem, + FunctionDeclaration, + MixinDeclaration, + InsertTextFormat, + FunctionParameter, + CompletionItemKind, + Use, + Forward, + Node, + Module, + MarkupKind, + SymbolKind, + CompletionItemTag, + MixinReference, + ForStatement, + EachStatement, + Identifier, + CompletionList, + DocumentLink, + FileType, + Position, + SassDocumentSymbol, + TextDocument, + URI, + Utils, +} from "../language-services-types"; +import { asDollarlessVariable } from "../utils/sass"; +import { applySassDoc } from "../utils/sassdoc"; + +const reNewSassdocBlock = /\/\/\/\s?$/; +const reSassdocLine = /\/\/\/\s/; +const reSassDotExt = /\.s(a|c)ss$/; +const rePrivate = /^\$?[_].*$/; + +const reReturn = /^.*@return/; +const reEach = /^.*@each .+ in /; +const reFor = /^.*@for .+ from /; +const reIf = /^.*@if /; +const reElseIf = /^.*@else if /; +const reWhile = /^.*@while /; +const rePropertyValue = /.*:\s*/; +const reEmptyPropertyValue = /.*:\s*$/; +const reQuotedValueInString = /["'](?:[^"'\\]|\\.)*["']/g; +const reMixinReference = /.*@include\s+(.*)/; +const reComment = /^(.*\/\/|.*\/\*|\s*\*)/; +const reSassDoc = /^[\\s]*\/{3}.*$/; +const reQuotes = /["']/; +const rePlaceholder = /@extend\s+/; +const rePlaceholderDeclaration = /^\s*%/; +const rePartialModuleAtRule = /@(?:use|forward|import) ["']/; + +type CompletionContext = { + currentWord: string; + namespace?: string; + isMixinContext?: boolean; + isFunctionContext?: boolean; + isVariableContext?: boolean; + isPlaceholderContext?: boolean; + isPlaceholderDeclarationContext?: boolean; + isCommentContext?: boolean; + isSassdocContext?: boolean; + isImportContext?: boolean; +}; + +export class DoComplete extends LanguageFeature { + async doComplete( + document: TextDocument, + position: Position, + ): Promise { + const result = CompletionList.create([]); + const upstreamLs = this.getUpstreamLanguageServer(); + + const context = this.createCompletionContext(document, position); + + const stylesheet = this.ls.parseStylesheet(document); + const offset = document.offsetAt(position); + let node = getNodeAtOffset(stylesheet, offset); + + // In a handful of cases we don't get a node because our offset lands on a whitespace of + // an incomplete declaration, for instance "@include ". Try to look back at offset - 1 and + // see if we get a node there. + if (!node && offset > 0) { + node = getNodeAtOffset(stylesheet, offset - 1); + } + + if (context.isSassdocContext) { + const scanner = this.getScanner(document); + let token: IToken = scanner.scan(); + let prevToken: IToken | null = null; + while (token.type !== TokenType.EOF) { + // Lookback is needed to figure out if we should do Sassdoc block completion. + // It should happen if we hit a function or mixin declaration with `///` + // (and an optional space) as the previous token. If we overshoot offset + // and that has not happened we don't really care about the rest of the + // document and break out of the loop. + if (prevToken && prevToken.offset + prevToken.len > offset) { + break; + } + + // Don't start processing the token until we've reached the token under the cursor + if (token.offset + token.len < offset) { + prevToken = token; + token = scanner.scan(); + continue; + } + + if (token.type === TokenType.AtKeyword) { + const keyword = token.text.toLowerCase(); + const isFunction = keyword === "@function"; + const isMixin = keyword === "@mixin"; + if (isFunction || isMixin) { + if (prevToken && prevToken.text.match(reNewSassdocBlock)) { + const node = getNodeAtOffset(stylesheet, token.offset); + if ( + node && + (node instanceof MixinDeclaration || + node instanceof FunctionDeclaration) + ) { + const item = this.doSassdocBlockCompletion(document, node); + result.items.push(item); + } + } + } + } + + if ( + token.type === TokenType.Comment && + token.text.match(reSassdocLine) + ) { + const beforeCursor = token.text.substring(0, offset - token.offset); + const items = this.doSassdocAnnotationCompletion(beforeCursor); + result.items.push(...items); + } + + prevToken = token; + token = scanner.scan(); + } + + if (result.items.length > 0) { + return result; + } + } + + if (context.isCommentContext) { + return result; + } + + if (context.isImportContext) { + // Upstream includes thing like suggestions based on relative paths + // and imports of built-in sass modules like sass:color and sass:math + const upstreamResult = await upstreamLs.doComplete2( + document, + position, + stylesheet, + this.getDocumentContext(), + { + ...this.configuration.completionSettings, + triggerPropertyValueCompletion: + this.configuration.completionSettings + ?.triggerPropertyValueCompletion || false, + }, + ); + if (upstreamResult.items.length > 0) { + result.items.push(...upstreamResult.items); + } + + if ( + node && + node.parent && + (node.parent instanceof Use || node.parent instanceof Forward) + ) { + const items = await this.doModuleImportCompletion(document, node); + if (items.length > 0) { + result.items.push(...items); + } + } + + return result; + } + + if (context.isPlaceholderDeclarationContext) { + const items = await this.doPlaceholderDeclarationCompletion(); + if (items.length > 0) { + result.items.push(...items); + } + return result; + } + + if (context.isPlaceholderContext) { + const items = await this.doPlaceholderUsageCompletion(document); + if (items.length > 0) { + result.items.push(...items); + } + return result; + } + + /* Completions for variables, functions and mixins */ + + // At this point we're at `@for ` and will declare a variable. + // We don't need suggestions here. + const forDeclaration = node instanceof ForStatement && !node.hasChildren(); + if (forDeclaration) { + return result; + } + + // At this point we're at `@each ` and will declare a variable. + // We don't need suggestions here. + const eachDeclaration = + node instanceof EachStatement && !node.variables?.hasChildren(); + if (eachDeclaration) { + return result; + } + + if (context.namespace) { + const items = await this.doNamespaceCompletion(document, context); + if (items.length > 0) { + result.items.push(...items); + } + } + + // We might be looking at a wildcard alias (@use "./foo" as *), so check the links and see if we need to go looking + const links = await this.ls.findDocumentLinks(document); + const wildcards: DocumentLink[] = []; + for (const link of links) { + if (link.as === "*") { + wildcards.push(link); + } + } + if (wildcards.length > 0) { + const items = await this.doWildcardCompletion(document, wildcards, { + ...context, + namespace: "*", + }); + if (items.length > 0) { + result.items.push(...items); + } + } + + // Legacy @import style suggestions + if (!this.configuration.completionSettings?.suggestFromUseOnly) { + const currentWord = context.currentWord; + const documents = this.cache.documents(); + for (const currentDocument of documents) { + if ( + !this.configuration.completionSettings?.suggestAllFromOpenDocument && + currentDocument.uri === document.uri + ) { + continue; + } + + const symbols = this.ls.findDocumentSymbols(currentDocument); + for (const symbol of symbols) { + const isPrivate = Boolean(symbol.name.match(rePrivate)); + if (isPrivate && currentDocument.uri !== document.uri) { + continue; + } + + switch (symbol.kind) { + case SymbolKind.Variable: { + if (!context.isVariableContext) break; + + const items = await this.doVariableCompletion( + document, + currentDocument, + currentWord, + symbol, + isPrivate, + ); + if (items.length > 0) { + result.items.push(...items); + } + break; + } + case SymbolKind.Method: { + if (!context.isMixinContext) break; + + const items = await this.doMixinCompletion( + document, + currentDocument, + currentWord, + symbol, + isPrivate, + ); + if (items.length > 0) { + result.items.push(...items); + } + break; + } + case SymbolKind.Function: { + if (!context.isFunctionContext) break; + + const items = await this.doFunctionCompletion( + document, + currentDocument, + currentWord, + symbol, + isPrivate, + ); + if (items.length > 0) { + result.items.push(...items); + } + break; + } + } + } + } + + if (result.items.length > 0) { + return result; + } + + // If we don't have any suggestions, maybe upstream does + const upstreamResult = await upstreamLs.doComplete2( + document, + position, + stylesheet, + this.getDocumentContext(), + { + ...this.configuration.completionSettings, + triggerPropertyValueCompletion: + this.configuration.completionSettings + ?.triggerPropertyValueCompletion || false, + }, + ); + return upstreamResult; + } + + const upstreamResult = await upstreamLs.doComplete2( + document, + position, + stylesheet, + this.getDocumentContext(), + { + ...this.configuration.completionSettings, + triggerPropertyValueCompletion: + this.configuration.completionSettings + ?.triggerPropertyValueCompletion || false, + }, + ); + if (upstreamResult.items.length > 0) { + result.items.push(...upstreamResult.items); + } + return result; + } + + createCompletionContext( + document: TextDocument, + position: Position, + ): CompletionContext { + const text = document.getText(); + const offset = document.offsetAt(position); + let i = offset - 1; + while (!"\n\r".includes(text.charAt(i))) { + i--; + } + const lineBeforePosition = text.substring(i + 1, offset); + + i = offset - 1; + while (i >= 0 && !' \t\n\r":[()]}/,'.includes(text.charAt(i))) { + i--; + } + const currentWord = text.substring(i + 1, offset); + + if (rePartialModuleAtRule.test(lineBeforePosition)) { + return { + currentWord, + isImportContext: true, + }; + } + + if (reSassDoc.test(lineBeforePosition)) { + return { + currentWord, + isSassdocContext: true, + }; + } + + if (reComment.test(lineBeforePosition)) { + return { + currentWord, + isCommentContext: true, + }; + } + + if (rePlaceholder.test(lineBeforePosition)) { + return { + currentWord, + isPlaceholderContext: true, + }; + } + + if (rePlaceholderDeclaration.test(lineBeforePosition)) { + return { + currentWord, + isPlaceholderDeclarationContext: true, + }; + } + + const isInterpolation = currentWord.includes("#{"); + + const context: CompletionContext = { + currentWord, + }; + + // Is namespace, e.g. `namespace.$var` or `@include namespace.mixin` or `namespace.func()` + context.namespace = + currentWord.length === 0 || !currentWord.includes(".") + ? undefined + : currentWord.substring( + // Skip #{ if this is interpolation + isInterpolation ? currentWord.indexOf("{") + 1 : 0, + currentWord.indexOf("."), + ); + + const isReturn = reReturn.test(lineBeforePosition); + const isIf = reIf.test(lineBeforePosition); + const isElseIf = reElseIf.test(lineBeforePosition); + const isEach = reEach.test(lineBeforePosition); + const isFor = reFor.test(lineBeforePosition); + const isWhile = reWhile.test(lineBeforePosition); + const isPropertyValue = rePropertyValue.test(lineBeforePosition); + const isEmptyValue = reEmptyPropertyValue.test(lineBeforePosition); + const isQuotes = reQuotes.test( + lineBeforePosition.replace(reQuotedValueInString, ""), + ); + + const isControlFlow = + isReturn || isIf || isElseIf || isEach || isFor || isWhile; + + if ((isControlFlow || isPropertyValue) && !isEmptyValue && !isQuotes) { + if (context.namespace && currentWord.endsWith(".")) { + context.isVariableContext = true; + } else { + context.isVariableContext = currentWord.includes("$"); + } + } else if (isQuotes) { + context.isVariableContext = isInterpolation; + } else { + context.isVariableContext = + currentWord.startsWith("$") || isInterpolation || isEmptyValue; + } + + if ((isControlFlow || isPropertyValue) && !isEmptyValue && !isQuotes) { + if (context.namespace) { + context.isFunctionContext = true; + } else { + const lastChar = lineBeforePosition.charAt( + lineBeforePosition.length - 1, + ); + const triggers = + this.configuration.completionSettings + ?.suggestFunctionsInStringContextAfterSymbols; + if (triggers) { + context.isFunctionContext = triggers.includes(lastChar); + } + } + } else if (isQuotes) { + context.isFunctionContext = isInterpolation; + } else if (isPropertyValue && isEmptyValue) { + context.isFunctionContext = true; + } + + if (!isPropertyValue && reMixinReference.test(lineBeforePosition)) { + context.isMixinContext = true; + } + + return context; + } + + async doPlaceholderUsageCompletion( + initialDocument: TextDocument, + ): Promise { + const items: CompletionItem[] = []; + const result = await this.findInWorkspace((document) => { + const symbols = this.ls.findDocumentSymbols(document); + const items: CompletionItem[] = []; + for (const symbol of symbols) { + if (symbol.kind === SymbolKind.Class && symbol.name.startsWith("%")) { + const item: CompletionItem = this.toCompletionItem(document, symbol); + items.push(item); + } + } + return items; + }, initialDocument); + + if (result.length > 0) { + items.push(...result); + } + + if (!this.configuration.completionSettings?.suggestFromUseOnly) { + const documents = this.cache.documents(); + for (const current of documents) { + const symbols = this.ls.findDocumentSymbols(current); + for (const symbol of symbols) { + if (symbol.kind === SymbolKind.Class && symbol.name.startsWith("%")) { + const item: CompletionItem = this.toCompletionItem(current, symbol); + items.push(item); + } + } + } + } + + return items; + } + + private toCompletionItem(document: TextDocument, symbol: SassDocumentSymbol) { + const filterText = symbol.name.substring(1); + + let documentation = symbol.name; + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + documentation += `\n____\n${sassdoc}`; + } + + const detail = `Placeholder declared in ${this.getFileName(document.uri)}`; + + const item: CompletionItem = { + detail, + documentation, + filterText, + insertText: filterText, + insertTextFormat: InsertTextFormat.PlainText, + kind: CompletionItemKind.Class, + label: symbol.name, + tags: symbol.sassdoc?.deprecated + ? [CompletionItemTag.Deprecated] + : undefined, + }; + return item; + } + + /** + * Make completion items for each `%placeholder` used in an `@extend` statement. + * This is useful for workflows where the selectors often change, but the semantics + * are stable. + * + * @see https://github.com/wkillerud/some-sass/issues/49 + */ + async doPlaceholderDeclarationCompletion(): Promise { + const items: CompletionItem[] = []; + const documents = this.cache.documents(); + for (const currentDocument of documents) { + const symbols = this.ls.findDocumentSymbols(currentDocument); + for (const symbol of symbols) { + if (symbol.kind === SymbolKind.Class) { + if (!symbol.children) continue; + + // cssNavigation should only add these placeholder symbols as children + // if the node parent is an @extend reference, meaning a placeholder usage. + for (const child of symbol.children) { + if (child.kind === SymbolKind.Class && child.name.startsWith("%")) { + const filterText = child.name.substring(1); + items.push({ + filterText, + insertText: filterText, + insertTextFormat: InsertTextFormat.PlainText, + kind: CompletionItemKind.Class, + label: child.name, + }); + } + } + } + } + } + return items; + } + + async doNamespaceCompletion( + document: TextDocument, + context: CompletionContext, + ): Promise { + const items: CompletionItem[] = []; + + const namespace: string | undefined = context.namespace; + if (!namespace) { + return items; + } + + const links = await this.ls.findDocumentLinks(document); + let start: TextDocument | undefined = undefined; + for (const link of links) { + if ( + link.target && + link.type === NodeType.Use && + link.namespace === namespace + ) { + if (link.target.includes("sass:")) { + // Look for matches in built-in namespaces, which do not appear in storage + for (const [builtIn, docs] of Object.entries(sassBuiltInModules)) { + if (builtIn === link.target) { + const items = this.doSassBuiltInCompletion( + document, + context, + docs, + ); + return items; + } + } + } else { + start = this.cache.getDocument(link.target); + } + break; + } + } + + if (!start) { + return items; + } + + const result = await this.findCompletionsInWorkspace( + document, + context, + start, + ); + return result; + } + + async doWildcardCompletion( + document: TextDocument, + wildcards: DocumentLink[], + context: CompletionContext, + ): Promise { + const items: CompletionItem[] = []; + for (const link of wildcards) { + const start = this.cache.getDocument(link.target!); + if (!start) continue; + + const result = await this.findCompletionsInWorkspace( + document, + context, + start, + ); + + if (result.length > 0) { + items.push(...result); + } + } + return items; + } + + private async findCompletionsInWorkspace( + document: TextDocument, + context: CompletionContext, + start: TextDocument, + ) { + const result = await this.findInWorkspace( + async (currentDocument, prefix, hide, show) => { + const items: CompletionItem[] = []; + const symbols = this.ls.findDocumentSymbols(currentDocument); + for (const symbol of symbols) { + if (show.length > 0 && !show.includes(symbol.name)) { + continue; + } + if (hide.includes(symbol.name)) { + continue; + } + const isPrivate = Boolean(symbol.name.match(rePrivate)); + if (isPrivate && currentDocument.uri !== document.uri) { + continue; + } + + switch (symbol.kind) { + case SymbolKind.Variable: { + if (!context.isVariableContext) break; + + const vars = await this.doVariableCompletion( + document, + currentDocument, + context.currentWord, + symbol, + isPrivate, + context.namespace, + prefix, + ); + if (vars.length > 0) { + items.push(...vars); + } + break; + } + case SymbolKind.Method: { + if (!context.isMixinContext) break; + + const mixs = await this.doMixinCompletion( + document, + currentDocument, + context.currentWord, + symbol, + isPrivate, + context.namespace, + prefix, + ); + if (mixs.length > 0) { + items.push(...mixs); + } + break; + } + case SymbolKind.Function: { + if (!context.isFunctionContext) break; + + const funcs = await this.doFunctionCompletion( + document, + currentDocument, + context.currentWord, + symbol, + isPrivate, + context.namespace, + prefix, + ); + if (funcs.length > 0) { + items.push(...funcs); + } + break; + } + } + } + return items; + }, + start, + ); + return result; + } + + private async doVariableCompletion( + initialDocument: TextDocument, + currentDocument: TextDocument, + currentWord: string, + symbol: SassDocumentSymbol, + isPrivate: boolean, + namespace = "", + prefix = "", + ): Promise { + // Avoid ending up with namespace.prefix-$variable + const label = `$${prefix}${asDollarlessVariable(symbol.name)}`; + const rawValue = this.getVariableValue(currentDocument, symbol); + let value = await this.findValue( + currentDocument, + symbol.selectionRange.start, + ); + value = value || rawValue; + const color = value ? getColorValue(value) : null; + const completionKind = color + ? CompletionItemKind.Color + : CompletionItemKind.Variable; + + let documentation = + color || + [ + "```scss", + `${label}: ${value};${value !== rawValue ? ` // via ${rawValue}` : ""}`, + "```", + ].join("\n") || + ""; + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + documentation += `\n____\n${sassdoc}`; + } + documentation += `\n____\nVariable declared in ${this.getFileName(currentDocument.uri)}`; + + const sortText = isPrivate ? label.replace(/^$[_]/, "") : undefined; + + const dotExt = initialDocument.uri.slice( + Math.max(0, initialDocument.uri.lastIndexOf(".")), + ); + const isEmbedded = !dotExt.match(reSassDotExt); + let insertText: string | undefined; + let filterText: string | undefined; + + if (namespace && namespace !== "*") { + insertText = currentWord.endsWith(".") + ? `${isEmbedded ? "" : "."}${label}` + : isEmbedded + ? asDollarlessVariable(label) + : label; + + filterText = currentWord.endsWith(".") ? `${namespace}.${label}` : label; + } else if (dotExt === ".vue" || dotExt === ".astro") { + // In Vue and Astro files, the $ does not get replaced by the suggestion, + // so exclude it from the insertText. + insertText = asDollarlessVariable(label); + } + + const item: CompletionItem = { + commitCharacters: [";", ","], + documentation: + completionKind === CompletionItemKind.Color + ? documentation + : { + kind: MarkupKind.Markdown, + value: documentation, + }, + filterText, + kind: completionKind, + label, + insertText, + sortText, + tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], + }; + return [item]; + } + + private isEmbedded(initialDocument: TextDocument) { + const dotExt = initialDocument.uri.slice( + Math.max(0, initialDocument.uri.lastIndexOf(".")), + ); + const isEmbedded = !dotExt.match(reSassDotExt); + return isEmbedded; + } + + private async doMixinCompletion( + initialDocument: TextDocument, + currentDocument: TextDocument, + currentWord: string, + symbol: SassDocumentSymbol, + isPrivate: boolean, + namespace = "", + prefix = "", + ): Promise { + const items: CompletionItem[] = []; + + const label = `${prefix}${symbol.name}`; + const filterText = namespace + ? namespace !== "*" + ? `${namespace}.${prefix}${symbol.name}` + : `${prefix}${symbol.name}` + : symbol.name; + + const isEmbedded = this.isEmbedded(initialDocument); + + const insertText = namespace + ? namespace !== "*" && !isEmbedded + ? `.${prefix}${symbol.name}` + : `${prefix}${symbol.name}` + : symbol.name; + + const sortText = isPrivate ? label.replace(/^$[_]/, "") : undefined; + + const documentation = { + kind: MarkupKind.Markdown, + value: `\`\`\`scss\n@mixin ${symbol.name}${symbol.detail || "()"}\n\`\`\``, + }; + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + documentation.value += `\n____\n${sassdoc}`; + } + documentation.value += `\n____\nMixin declared in ${this.getFileName(currentDocument.uri)}`; + + const getCompletionVariants = ( + insertText: string, + detail?: string, + ): CompletionItem[] => { + const variants: CompletionItem[] = []; + // Not all mixins have @content, but when they do, be smart about adding brackets + // and move the cursor to be ready to add said contents. + // Include as separate suggestion since content may not always be needed or wanted. + + if ( + this.configuration.completionSettings?.suggestionStyle !== "bracket" + ) { + variants.push({ + documentation, + filterText, + kind: CompletionItemKind.Method, + label, + labelDetails: detail ? { detail: `(${detail})` } : undefined, + insertText, + insertTextFormat: InsertTextFormat.Snippet, + sortText, + tags: symbol.sassdoc?.deprecated + ? [CompletionItemTag.Deprecated] + : [], + }); + } + + if ( + this.configuration.completionSettings?.suggestionStyle !== "nobracket" + ) { + variants.push({ + documentation, + filterText, + kind: CompletionItemKind.Method, + label, + labelDetails: { detail: detail ? `(${detail}) { }` : " { }" }, + insertText: (insertText += " {\n\t$0\n}"), + insertTextFormat: InsertTextFormat.Snippet, + sortText, + tags: symbol.sassdoc?.deprecated + ? [CompletionItemTag.Deprecated] + : [], + }); + } + + return variants; + }; + + // In the case of no required parameters, skip details. + // If there are required parameters, add a suggestion with only them. + // If there are optional parameters, add a suggestion with all parameters. + if (symbol.detail) { + const parameters = getParametersFromDetail(symbol.detail); + const requiredParameters = parameters.filter((p) => !p.defaultValue); + if (requiredParameters.length > 0) { + const parametersSnippet = requiredParameters + .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) + .join(", "); + const insert = insertText + `(${parametersSnippet})`; + + const detail = requiredParameters + .map((p) => mapParameterSignature(p)) + .join(", "); + + items.push(...getCompletionVariants(insert, detail)); + } + if (requiredParameters.length !== parameters.length) { + const parametersSnippet = parameters + .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) + .join(", "); + const insert = insertText + `(${parametersSnippet})`; + + const detail = parameters + .map((p) => mapParameterSignature(p)) + .join(", "); + + items.push(...getCompletionVariants(insert, detail)); + } + } else { + items.push(...getCompletionVariants(insertText)); + } + return items; + } + + private async doFunctionCompletion( + initialDocument: TextDocument, + currentDocument: TextDocument, + currentWord: string, + symbol: SassDocumentSymbol, + isPrivate: boolean, + namespace = "", + prefix = "", + ): Promise { + const items: CompletionItem[] = []; + + const label = `${prefix}${symbol.name}`; + const filterText = namespace + ? `${namespace !== "*" ? namespace : ""}.${prefix}${symbol.name}` + : symbol.name; + + const isEmbedded = this.isEmbedded(initialDocument); + const insertText = namespace + ? namespace !== "*" && !isEmbedded + ? `.${prefix}${symbol.name}` + : `${prefix}${symbol.name}` + : symbol.name; + + const sortText = isPrivate ? label.replace(/^$[_]/, "") : undefined; + + const documentation = { + kind: MarkupKind.Markdown, + value: `\`\`\`scss\n@function ${symbol.name}${symbol.detail || "()"}\n\`\`\``, + }; + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + documentation.value += `\n____\n${sassdoc}`; + } + documentation.value += `\n____\nFunction declared in ${this.getFileName(currentDocument.uri)}`; + + // If there are required parameters, add a suggestion with only them. + // If there are optional parameters, add a suggestion with all parameters. + const parameters = getParametersFromDetail(symbol.detail); + const requiredParameters = parameters.filter((p) => !p.defaultValue); + const parametersSnippet = requiredParameters + .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) + .join(", "); + const detail = requiredParameters + .map((p) => mapParameterSignature(p)) + .join(", "); + + const item: CompletionItem = { + documentation, + filterText, + kind: CompletionItemKind.Function, + label, + labelDetails: { detail: `(${detail})` }, + insertText: `${insertText}(${parametersSnippet})`, + insertTextFormat: InsertTextFormat.Snippet, + sortText, + tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], + }; + items.push(item); + + if (requiredParameters.length !== parameters.length) { + const parametersSnippet = parameters + .map((p, i) => mapParameterSnippet(p, i, symbol.sassdoc)) + .join(", "); + const detail = parameters.map((p) => mapParameterSignature(p)).join(", "); + + const item: CompletionItem = { + documentation, + filterText, + kind: CompletionItemKind.Function, + label, + labelDetails: { detail: `(${detail})` }, + insertText: `${insertText}(${parametersSnippet})`, + insertTextFormat: InsertTextFormat.Snippet, + sortText, + tags: symbol.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], + }; + items.push(item); + } + + return items; + } + + doSassBuiltInCompletion( + document: TextDocument, + context: CompletionContext, + moduleDocs: SassBuiltInModule, + ): CompletionItem[] { + const items: CompletionItem[] = []; + for (const [name, docs] of Object.entries(moduleDocs.exports)) { + const { description, signature, parameterSnippet, returns } = docs; + // Client needs the namespace as part of the text that is matched, + const filterText = `${context.namespace}.${name}`; + + // Inserted text needs to include the `.` which will otherwise + // be replaced (except when we're embedded in Vue, Svelte or Astro). + // Example result: .floor(${1:number}) + const isEmbedded = this.isEmbedded(document); + + const insertText = context.currentWord.includes(".") + ? `${isEmbedded ? "" : "."}${name}${ + signature ? `(${parameterSnippet})` : "" + }` + : name; + + items.push({ + documentation: { + kind: MarkupKind.Markdown, + value: `${description}\n\n[Sass documentation](${moduleDocs.reference}#${name})`, + }, + filterText, + insertText, + insertTextFormat: parameterSnippet + ? InsertTextFormat.Snippet + : InsertTextFormat.PlainText, + kind: signature + ? CompletionItemKind.Function + : CompletionItemKind.Variable, + label: name, + labelDetails: { + detail: + signature && returns ? `${signature} => ${returns}` : signature, + }, + }); + } + + return items; + } + + async doModuleImportCompletion( + document: TextDocument, + node: Node, + ): Promise { + const items: CompletionItem[] = []; + const url = node.getText().replace(/["']/g, ""); + const moduleName = getModuleNameFromPath(url); + + const rootFolderUri = this.configuration.workspaceRoot + ? Utils.joinPath(this.configuration.workspaceRoot, "/").toString(true) + : ""; + const documentFolderUri = Utils.dirname(URI.parse(document.uri)).toString( + true, + ); + + if (moduleName && moduleName !== "." && moduleName !== "..") { + const modulePath = await this.resolvePathToModule( + moduleName, + documentFolderUri, + rootFolderUri, + ); + if (modulePath) { + const pathWithinModule = url.substring(moduleName.length + 1); + const pathInsideModule = Utils.joinPath( + URI.parse(modulePath), + pathWithinModule, + ); + const filesInModulePath = + await this.options.fileSystemProvider.readDirectory(pathInsideModule); + for (const [name, fileType] of filesInModulePath) { + const file = name; + if (fileType === FileType.File && file.match(reSassDotExt)) { + const filename = file.startsWith("/") ? file.slice(1) : file; + // Prefer to insert without file extension + let insertText = filename.slice(0, -5); + if (insertText.startsWith("/")) { + insertText = insertText.slice(1); + } + if (insertText.startsWith("_")) { + insertText = insertText.slice(1); + } + items.push({ + label: escapePath(filename), + insertText: escapePath(insertText), + kind: CompletionItemKind.File, + }); + } else if (fileType === FileType.Directory) { + let insertText = escapePath(file); + if (insertText.startsWith("/")) { + insertText = insertText.slice(1); + } + insertText = `${insertText}/`; + items.push({ + label: insertText, + kind: CompletionItemKind.Folder, + insertText, + command: { + title: "Suggest", + command: "editor.action.triggerSuggest", + }, + }); + } + } + } + } + + if (!moduleName && url === "pkg:") { + // Find the way to the nearest node_modules and list entries. + // This won't cover all scenarios (like workspaces) or package managers, but + // is better than nothing. + const nodeModules = await this.resolvePathToNodeModules( + documentFolderUri, + rootFolderUri, + ); + if (nodeModules) { + const folders = + await this.options.fileSystemProvider.readDirectory(nodeModules); + for (const [name, fileType] of folders) { + if (name.startsWith(".")) continue; + + if (fileType === FileType.Directory) { + let insertText = escapePath(name); + if (insertText.startsWith("/")) { + insertText = insertText.slice(1); + } + insertText = `${insertText}`; + items.push({ + label: insertText, + kind: CompletionItemKind.Folder, + insertText, + command: { + title: "Suggest", + command: "editor.action.triggerSuggest", + }, + }); + } + } + } + } + + return items; + } + + async resolvePathToModule( + _moduleName: string, + documentFolderUri: string, + rootFolderUri: string | undefined, + ): Promise { + // resolve the module relative to the document. We can't use `require` here as the code is webpacked. + + const packPath = Utils.joinPath( + URI.parse(documentFolderUri), + "node_modules", + _moduleName, + "package.json", + ); + if (await this.options.fileSystemProvider.exists(packPath)) { + return Utils.dirname(packPath).toString(true); + } else if ( + rootFolderUri && + documentFolderUri.startsWith(rootFolderUri) && + documentFolderUri.length !== rootFolderUri.length + ) { + return this.resolvePathToModule( + _moduleName, + Utils.dirname(URI.parse(documentFolderUri)).toString(true), + rootFolderUri, + ); + } + return undefined; + } + + async resolvePathToNodeModules( + documentFolderUri: string, + rootFolderUri: string | undefined, + ): Promise { + // resolve the module relative to the document. We can't use `require` here as the code is webpacked. + + const dirPath = Utils.joinPath( + URI.parse(documentFolderUri), + "node_modules", + ); + if (await this.options.fileSystemProvider.exists(dirPath)) { + return dirPath; + } else if ( + rootFolderUri && + documentFolderUri.startsWith(rootFolderUri) && + documentFolderUri.length !== rootFolderUri.length + ) { + return this.resolvePathToNodeModules( + Utils.dirname(URI.parse(documentFolderUri)).toString(true), + rootFolderUri, + ); + } + return undefined; + } + + doSassdocAnnotationCompletion(beforeCursor: string): CompletionItem[] { + if (beforeCursor.includes("@example")) { + return [ + { + label: "scss", + sortText: "-", // Give highest priority + kind: CompletionItemKind.Value, + }, + { + label: "css", + kind: CompletionItemKind.Value, + }, + { + label: "markup", + kind: CompletionItemKind.Value, + }, + { + label: "javascript", + sortText: "y", // Give lowest priority + kind: CompletionItemKind.Value, + }, + ]; + } + + const items: CompletionItem[] = []; + for (const { + annotation, + aliases, + insertText, + insertTextFormat, + } of sassDocAnnotations) { + const item = { + label: annotation, + kind: CompletionItemKind.Keyword, + insertText, + insertTextFormat, + sortText: "-", // Give highest priority + }; + + items.push(item); + + if (aliases) { + for (const alias of aliases) { + items.push({ + ...item, + label: alias, + insertText: insertText + ? insertText.replace(annotation, alias) + : insertText, + }); + } + } + } + + return items; + } + + /** + * Generates a suggestion for a Sassdoc block above a mixin or function that includes its parameters. + */ + doSassdocBlockCompletion( + document: TextDocument, + node: FunctionDeclaration | MixinDeclaration, + ): CompletionItem { + const isMixin = node.type === NodeType.MixinDeclaration; + + // Incremented when used, starting at position zero below. + // This ensures each snippet gets a unique tab position, ending at + // position 0 which is the description for the block itself. + let position = 0; + let snippet = ` \${${position++}}`; // " ${0}" + + const parameters = node + .getParameters() + .getChildren() as FunctionParameter[]; + + for (const parameter of parameters) { + const name = parameter.getName(); + const defaultValue = parameter.getDefaultValue()?.getText(); + let typeSnippet = "type"; + let defaultValueSnippet = ""; + if (defaultValue) { + defaultValueSnippet = ` [${defaultValue}]`; + + // Try to give a sensible default type if we can + if (defaultValue === "true" || defaultValue === "false") { + typeSnippet = "Boolean"; + } else if (/^["']/.exec(defaultValue)) { + typeSnippet = "String"; + } else if ( + defaultValue.startsWith("#") || + defaultValue.startsWith("rgb") || + defaultValue.startsWith("hsl") + ) { + typeSnippet = "Color"; + } else { + const maybeNumber = Number.parseFloat(defaultValue); + if (!Number.isNaN(maybeNumber)) { + typeSnippet = "Number"; + } + } + } + + // A parameter snippet such as the one below. The escape sequence "\\${name}" is needed to get the $ of variable names as part of the snippet output. + // "/// @param {$1:Number} \$start [0] ${2:-}" + snippet += `\n/// @param {\${${position++}:${typeSnippet}}} \\${name}${defaultValueSnippet} \${${position++}:-}`; + } + + if (isMixin) { + const text = node.getText(); + const hasContentAtKeyword = text.includes("@content"); + if (hasContentAtKeyword) { + snippet += `\n/// @content \${${position++}}`; + } + snippet += `\n/// @output \${${position++}}`; + } else { + snippet += `\n/// @return {\${${position++}:type}} \${${position++}:-}`; + } + + return { + label: "SassDoc Block", + insertText: snippet, + insertTextFormat: InsertTextFormat.Snippet, + sortText: "-", // Give highest priority + }; + } + + getModuleNode(document: TextDocument, node: Node | null): Module | null { + if (!node) return null; + + switch (node.type) { + case NodeType.MixinReference: { + const identifier = (node as MixinReference).getIdentifier(); + if ( + identifier && + identifier.parent && + identifier.parent.type === NodeType.Module + ) { + return identifier.parent as Module; + } + return null; + } + case NodeType.Module: { + return node as Module; + } + case NodeType.Identifier: { + if (node.parent && node.parent.type === NodeType.Module) { + return node.parent as Module; + } + return null; + } + default: { + const text = node.getText(); + const interpolationStart = text.indexOf("#{"); + if (interpolationStart !== -1) { + const dotDelim = text.indexOf(".", interpolationStart + 2); + if (dotDelim !== -1) { + const maybeNamespace = text.substring( + interpolationStart + 2, + dotDelim + 1, + ); + const module = new Module( + node.offset + interpolationStart + 2, + maybeNamespace.length, + NodeType.Module, + ); + const identifier = new Identifier( + node.offset + interpolationStart + 2, + maybeNamespace.length - 1, + ); + module.setIdentifier(identifier); + module.parent = node; // to get access to textProvider + return module; + } + } else if (this.isEmbedded(document)) { + const dotIndex = text.indexOf("."); + if (dotIndex !== -1) { + let startOffset = dotIndex; + const endOffset = dotIndex; + while (startOffset > 0) { + const char = text.charAt(startOffset - 1); + if (char.match(/\s/)) { + break; + } + startOffset -= 1; + } + + const module = new Module( + node.offset + startOffset, + endOffset - startOffset, + NodeType.Module, + ); + const identifier = new Identifier( + node.offset + startOffset, + endOffset - startOffset, + ); + module.setIdentifier(identifier); + module.parent = node; // to get access to textProvider + return module; + } + } + return null; + } + } + } +} + +function getModuleNameFromPath(modulePath: string) { + let path = modulePath; + + // Slice away deprecated tilde import + if (path.startsWith("~")) { + path = path.slice(1); + } + + const firstSlash = path.indexOf("/"); + if (firstSlash === -1) { + return ""; + } + + // If a scoped module (starts with @) then get up until second instance of '/', or to the end of the string for root-level imports. + if (path[0] === "@") { + const secondSlash = path.indexOf("/", firstSlash + 1); + if (secondSlash === -1) { + return path; + } + return path.substring(0, secondSlash); + } + // Otherwise get until first instance of '/' + return path.substring(0, firstSlash); +} + +// Escape https://www.w3.org/TR/CSS1/#url +function escapePath(p: string) { + return p.replace(/(\s|\(|\)|,|"|')/g, "\\$1"); +} + +function getColorValue(from: string): string | null { + try { + ColorDotJS.parse(from); + return from; + } catch { + return null; + } +} + +type Parameter = { + name: string; + defaultValue?: string; +}; + +function getParametersFromDetail(detail?: string): Array { + const result: Parameter[] = []; + if (!detail) { + return result; + } + + const parameters = detail.replace(/[()]/g, "").split(","); + for (const param of parameters) { + let name = param; + let defaultValue: string | undefined = undefined; + const defaultValueStart = param.indexOf(":"); + if (defaultValueStart !== -1) { + name = param.substring(0, defaultValueStart); + defaultValue = param.substring(defaultValueStart + 1); + } + + const parameter: Parameter = { + name: name.trim(), + defaultValue: defaultValue?.trim(), + }; + + result.push(parameter); + } + return result; +} + +/** + * Use the SnippetString syntax to provide smart completions of parameter names. + */ +function mapParameterSnippet( + p: Parameter, + index: number, + sassdoc?: ParseResult, +): string { + const dollarlessVariable = asDollarlessVariable(p.name); + + const parameterDocs = + sassdoc && sassdoc.parameter + ? sassdoc.parameter.find((p) => p.name === dollarlessVariable) + : undefined; + + if (parameterDocs?.type?.length) { + const choices = parseStringLiteralChoices(parameterDocs.type); + if (choices.length > 0) { + return `\${${index + 1}|${choices.join(",")}|}`; + } + } + + return `\${${index + 1}:${dollarlessVariable}}`; +} + +function mapParameterSignature(p: Parameter): string { + return p.defaultValue ? `${p.name}: ${p.defaultValue}` : p.name; +} + +const reStringLiteral = /^["'].+["']$/; // Yes, this will match 'foo", but let the parser deal with yelling about that. + +/** + * @param docstring A TypeScript-like string of accepted string literal values, for example `"standard" | "entrance" | "exit"`. + */ +function parseStringLiteralChoices(docstring: string[] | string): string[] { + const docstrings = typeof docstring === "string" ? [docstring] : docstring; + const result: string[] = []; + + for (const doc of docstrings) { + const parts = doc.split("|"); + if (parts.length === 1) { + // This may be a docstring to indicate only a single valid string literal option. + const trimmed = doc.trim(); + if (reStringLiteral.test(trimmed)) { + result.push(trimmed); + } + } else { + for (const part of parts) { + const trimmed = part.trim(); + if (reStringLiteral.test(trimmed)) { + result.push(trimmed); + } + } + } + } + + return result; +} diff --git a/packages/language-services/src/features/do-diagnostics.ts b/packages/language-services/src/features/do-diagnostics.ts new file mode 100644 index 00000000..163b88a3 --- /dev/null +++ b/packages/language-services/src/features/do-diagnostics.ts @@ -0,0 +1,108 @@ +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + MixinReference, + Variable, + Diagnostic, + Node, + NodeType, + Function, + Range, + DiagnosticTag, + DiagnosticSeverity, +} from "../language-services-types"; + +export class DoDiagnostics extends LanguageFeature { + async doDiagnostics(document: TextDocument): Promise { + return this.doDeprecationDiagnostics(document); + } + + private async doDeprecationDiagnostics( + document: TextDocument, + ): Promise { + const references = this.getReferences(document); + + const diagnostics: Diagnostic[] = []; + for (const node of references) { + const definition = await this.ls.findDefinition( + document, + document.positionAt(node.offset), + ); + if (!definition) continue; + + const name = + node.type === NodeType.SelectorPlaceholder + ? node.getText() + : (node as Variable | Function | MixinReference).getName(); + + const symbol = await this.findDefinitionSymbol(definition, name); + + if (!symbol) continue; + if (typeof symbol.sassdoc?.deprecated === "undefined") continue; + + let range: Range | null = null; + if ( + node.type === NodeType.MixinReference || + node.type === NodeType.Function + ) { + const ident = (node as Function | MixinReference).getIdentifier(); + if (ident) { + range = Range.create( + document.positionAt(ident.offset), + document.positionAt(ident.end), + ); + } + } + + diagnostics.push({ + message: symbol.sassdoc.deprecated || `${symbol.name} is deprecated`, + range: + range || + Range.create( + document.positionAt(node.offset), + document.positionAt(node.end), + ), + source: "Some Sass", + tags: [DiagnosticTag.Deprecated], + severity: DiagnosticSeverity.Hint, + }); + } + return diagnostics; + } + + private getReferences(document: TextDocument): Node[] { + const references: Node[] = []; + const stylesheet = this.ls.parseStylesheet(document); + stylesheet.accept((node) => { + switch (node.type) { + case NodeType.VariableName: { + if ( + node.parent && + node.parent.type !== NodeType.FunctionParameter && + node.parent.type !== NodeType.VariableDeclaration + ) { + references.push(node); + } + break; + } + case NodeType.MixinReference: + case NodeType.Function: { + references.push(node); + break; + } + case NodeType.SelectorPlaceholder: { + const nodeList = node.parent; + if (!nodeList) break; + + const atExtend = nodeList.parent; + if (atExtend && atExtend.type === NodeType.ExtendsReference) { + references.push(node); + } + break; + } + } + return true; + }); + return references; + } +} diff --git a/packages/language-services/src/features/do-hover.ts b/packages/language-services/src/features/do-hover.ts new file mode 100644 index 00000000..854056d8 --- /dev/null +++ b/packages/language-services/src/features/do-hover.ts @@ -0,0 +1,382 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import { sassBuiltInModules } from "../facts/sass"; +import { sassDocAnnotations } from "../facts/sassdoc"; +import { LanguageFeature } from "../language-feature"; +import { + IToken, + MarkupKind, + Range, + TokenType, + TextDocument, + Position, + Hover, + NodeType, + Variable, + SymbolKind, + MixinReference, + Function, + SassDocumentSymbol, +} from "../language-services-types"; +import { asDollarlessVariable } from "../utils/sass"; +import { applySassDoc } from "../utils/sassdoc"; + +export class DoHover extends LanguageFeature { + async doHover( + document: TextDocument, + position: Position, + ): Promise { + const stylesheet = this.ls.parseStylesheet(document); + const offset = document.offsetAt(position); + + let nodeType: NodeType; + const hoverNode = getNodeAtOffset(stylesheet, offset); + if (hoverNode) { + nodeType = hoverNode.type; + } else { + // If the document begins with a SassDoc comment the Stylesheet node does not begin at offset 0, + // instead starting where the SassDoc block ends. To ensure we get down to the switch below to + // look for Sassdoc annotations, set nodeType to Stylesheet here. + nodeType = NodeType.Stylesheet; + } + + let kind: SymbolKind | undefined; + let name: string | undefined; + let range: Range | undefined = undefined; + switch (nodeType) { + case NodeType.VariableName: { + const parent = hoverNode?.getParent(); + if ( + parent && + parent.type !== NodeType.VariableDeclaration && + parent.type !== NodeType.FunctionParameter + ) { + name = (hoverNode as Variable).getName(); + kind = SymbolKind.Variable; + } + break; + } + case NodeType.Identifier: { + let node; + let type: SymbolKind | null = null; + const parent = hoverNode?.getParent(); + if (parent && parent.type === NodeType.Function) { + node = parent; + type = SymbolKind.Function; + } else if (parent && parent.type === NodeType.MixinReference) { + node = parent; + type = SymbolKind.Method; + } + if (type === null) { + return null; + } + if (node) { + name = (node as Function | MixinReference).getName(); + kind = type; + } + break; + } + + case NodeType.MixinReference: { + name = (hoverNode as MixinReference)?.getName(); + kind = SymbolKind.Method; + break; + } + + case NodeType.Stylesheet: { + // Hover information for SassDoc. + // SassDoc is considered a comment, which are skipped by the regular parser (so we hit the Stylesheet node). + // Use the base scanner to retokenize the document including comments, + // and look a comment token at the hover position. + const scanner = this.getScanner(document); + let token: IToken = scanner.scan(); + while (token.type !== TokenType.EOF) { + if (token.offset + token.len < offset) { + token = scanner.scan(); + continue; + } + + if (token.type === TokenType.Comment) { + const commentText = token.text; + const candidate = sassDocAnnotations.find( + ({ annotation, aliases }) => { + return ( + commentText.includes(annotation) || + aliases?.some((alias) => commentText.includes(alias)) + ); + }, + ); + if (!candidate) { + // No Sassdoc annotations in the comment + break; + } + + const annotationStart = + token.offset + commentText.indexOf(candidate.annotation) - 1; + const annotationEnd = + annotationStart + candidate.annotation.length + 1; + + const hoveringAboveAnnotation = + annotationEnd > offset && offset > annotationStart; + + if (!hoveringAboveAnnotation) { + break; + } + + return { + contents: { + kind: MarkupKind.Markdown, + value: [ + candidate.annotation, + "____", + `[SassDoc reference](http://sassdoc.com/annotations/#${candidate.annotation.slice( + 1, + )})`, + ].join("\n"), + }, + }; + } + token = scanner.scan(); + } + break; + } + + case NodeType.SelectorPlaceholder: { + name = hoverNode?.getText(); + kind = SymbolKind.Class; + break; + } + } + + if (hoverNode && name && kind) { + range = Range.create( + document.positionAt(hoverNode.offset), + document.positionAt(hoverNode.offset + name.length), + ); + + // Traverse the workspace looking for a symbol of kinds.includes(symbol.kind) && name === symbol.name + const result = await this.findInWorkspace< + [TextDocument, SassDocumentSymbol] + >( + (document, prefix) => { + const symbols = this.ls.findDocumentSymbols(document); + for (const symbol of symbols) { + if (symbol.kind === kind) { + const prefixedSymbol = `${prefix}${asDollarlessVariable(symbol.name)}`; + const prefixedName = asDollarlessVariable(name!); + if (prefixedSymbol === prefixedName) { + return [[document, symbol]]; + } + } + } + }, + document, + { lazy: true }, + ); + + let symbolDocument: TextDocument | null = null; + let symbol: SassDocumentSymbol | null = null; + if (result.length !== 0) { + [symbolDocument, symbol] = result[0]; + } else { + // Fall back to looking through all the things, assuming folks use @import + const documents = this.cache.documents(); + for (const document of documents) { + const symbols = this.ls.findDocumentSymbols(document); + for (const sym of symbols) { + if (sym.kind === kind && sym.name === name) { + symbolDocument = document; + symbol = sym; + break; + } + } + } + } + + if (symbol && symbolDocument) { + switch (symbol.kind) { + case SymbolKind.Variable: { + const hover = await this.getVariableHoverContent( + symbolDocument, + symbol, + name, + ); + hover.range = range; + return hover; + } + case SymbolKind.Method: { + const hover = this.getMixinHoverContent( + symbolDocument, + symbol, + name, + ); + hover.range = range; + return hover; + } + case SymbolKind.Function: { + const hover = this.getFunctionHoverContent( + symbolDocument, + symbol, + name, + ); + hover.range = range; + return hover; + } + case SymbolKind.Class: { + const hover = this.getPlaceholderHoverContent( + symbolDocument, + symbol, + ); + hover.range = range; + return hover; + } + } + } + } + + if (hoverNode) { + // Look to see if this is a built-in, but only if we have no other content. + // Folks may use the same names as built-ins in their modules. + for (const { reference, exports } of Object.values(sassBuiltInModules)) { + for (const [builtinName, { description }] of Object.entries(exports)) { + if (builtinName === name) { + // Make sure we're not just hovering over a CSS function. + // Confirm we are looking at something that is the child of a module. + const isModule = + hoverNode.getParent()?.type === NodeType.Module || + hoverNode.getParent()?.getParent()?.type === NodeType.Module; + if (isModule) { + return { + contents: { + kind: MarkupKind.Markdown, + value: [ + description, + "", + `[Sass reference](${reference}#${builtinName})`, + ].join("\n"), + }, + }; + } + } + } + } + } + + // Lastly, fall back to CSS hover information + return this.getUpstreamLanguageServer().doHover( + document, + position, + stylesheet, + ); + } + + getFunctionHoverContent( + document: TextDocument, + symbol: SassDocumentSymbol, + maybePrefixedName: string, + ): Hover { + const result = { + kind: MarkupKind.Markdown, + value: [ + "```scss", + `@function ${maybePrefixedName}${symbol.detail || "()"}`, + "```", + ].join("\n"), + }; + + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + result.value += `\n____\n${sassdoc}`; + } + + const prefixInfo = + maybePrefixedName !== symbol.name ? ` as ${symbol.name}` : ""; + result.value += `\n____\nFunction declared${prefixInfo} in ${this.getFileName(document.uri)}`; + + return { + contents: result, + }; + } + + getMixinHoverContent( + document: TextDocument, + symbol: SassDocumentSymbol, + maybePrefixedName: string, + ): Hover { + const result = { + kind: MarkupKind.Markdown, + value: [ + "```scss", + `@mixin ${maybePrefixedName}${symbol.detail || "()"}`, + "```", + ].join("\n"), + }; + + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + result.value += `\n____\n${sassdoc}`; + } + + const prefixInfo = + maybePrefixedName !== symbol.name ? ` as ${symbol.name}` : ""; + result.value += `\n____\nMixin declared${prefixInfo} in ${this.getFileName(document.uri)}`; + + return { + contents: result, + }; + } + + getPlaceholderHoverContent( + document: TextDocument, + symbol: SassDocumentSymbol, + ): Hover { + const result = { + kind: MarkupKind.Markdown, + value: ["```scss", symbol.name, "```"].join("\n"), + }; + + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + result.value += `\n____\n${sassdoc}`; + } + + result.value += `\n____\nPlaceholder declared in ${this.getFileName(document.uri)}`; + + return { + contents: result, + }; + } + + private async getVariableHoverContent( + document: TextDocument, + symbol: SassDocumentSymbol, + maybePrefixedName: string, + ): Promise { + const rawValue = this.getVariableValue(document, symbol) || ""; + let value = await this.findValue(document, symbol.selectionRange.start); + value = value || rawValue; + + const result = { + kind: MarkupKind.Markdown, + value: [ + "```scss", + `${maybePrefixedName}: ${value};${ + value !== rawValue ? ` // via ${rawValue}` : "" + }`, + "```", + ].join("\n"), + }; + + const sassdoc = applySassDoc(symbol); + if (sassdoc) { + result.value += `\n____\n${sassdoc}`; + } + + const prefixInfo = + maybePrefixedName !== symbol.name ? ` as ${symbol.name}` : ""; + result.value += `\n____\nVariable declared${prefixInfo} in ${this.getFileName(document.uri)}`; + + return { + contents: result, + }; + } +} diff --git a/packages/language-services/src/features/do-rename.ts b/packages/language-services/src/features/do-rename.ts new file mode 100644 index 00000000..fe3dedce --- /dev/null +++ b/packages/language-services/src/features/do-rename.ts @@ -0,0 +1,126 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import { + TextDocument, + Position, + WorkspaceEdit, + NodeType, + Range, + SymbolKind, + TextEdit, +} from "../language-services-types"; +import { FindReferences } from "./find-references"; + +const defaultBehavior = { defaultBehavior: true }; + +export class DoRename extends FindReferences { + async prepareRename( + document: TextDocument, + position: Position, + ): Promise< + null | { defaultBehavior: boolean } | { range: Range; placeholder: string } + > { + const stylesheet = this.ls.parseStylesheet(document); + const node = getNodeAtOffset(stylesheet, document.offsetAt(position)); + if (!node) return defaultBehavior; + + const references = await this.internalFindReferences(document, position, { + includeDeclaration: true, + }); + + if (!references.references.length) { + if ( + node.type === NodeType.Import || + node.type === NodeType.Forward || + node.type === NodeType.Use + ) { + // No renaming prefixes since we can't find all the symbols + return null; + } + + return defaultBehavior; + } + + // Keep existing behavior for built-ins, + // which is to rename each usage in the current document. + if (references.references[0].defaultBehavior) { + return defaultBehavior; + } + + const renameRange = Range.create( + document.positionAt(node.offset), + document.positionAt(node.end), + ); + + // Exclude the $ of the variable and % of the placeholder, + // since they're required. + if ( + references.references[0].kind === SymbolKind.Variable || + references.references[0].kind === SymbolKind.Class + ) { + renameRange.start.character += 1; + } + + // Exclude any forward-prefixes from the renaming. + if (references.declaration) { + const renamingName = node.getText(); + const definitionName = references.declaration.symbol.name; + if (renamingName !== definitionName) { + const diff = renamingName.length - definitionName.length; + renameRange.start.character += diff; + } + } + + return { + range: renameRange, + placeholder: document.getText(renameRange), + }; + } + + async doRename( + document: TextDocument, + position: Position, + newName: string, + ): Promise { + const references = await this.internalFindReferences(document, position, { + includeDeclaration: true, + }); + + if (!references.references.length) { + return null; + } + + const edits: WorkspaceEdit = { + changes: {}, + }; + + for (const { location, kind, name } of references.references) { + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + if (!edits.changes![location.uri]) { + edits.changes![location.uri] = []; + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + const range = location.range; + + // Exclude the $ of the variable and % of the placeholder, + // since they're required. + if (kind === SymbolKind.Variable || kind === SymbolKind.Class) { + range.start.character = range.start.character + 1; + } + + // Exclude any forward-prefixes from the renaming. + if (references.declaration) { + const definitionName = references.declaration.symbol.name; + if (name !== definitionName) { + const diff = name.length - definitionName.length; + range.start.character += diff; + } + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + edits.changes![location.uri].push(TextEdit.replace(range, newName)); + } + + return edits; + } +} diff --git a/packages/language-services/src/features/do-signature-help.ts b/packages/language-services/src/features/do-signature-help.ts new file mode 100644 index 00000000..65fe7540 --- /dev/null +++ b/packages/language-services/src/features/do-signature-help.ts @@ -0,0 +1,178 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import { sassBuiltInModules } from "../facts/sass"; +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + Position, + SignatureHelp, + SignatureInformation, + MarkupKind, + NodeType, + MixinReference, + Function, +} from "../language-services-types"; +import { asDollarlessVariable } from "../utils/sass"; +import { applySassDoc } from "../utils/sassdoc"; + +export class DoSignatureHelp extends LanguageFeature { + async doSignatureHelp( + document: TextDocument, + position: Position, + ): Promise { + const stylesheet = this.ls.parseStylesheet(document); + let node = getNodeAtOffset(stylesheet, document.offsetAt(position)) as + | Function + | MixinReference + | null; + + const result: SignatureHelp = { + activeSignature: 0, + activeParameter: 0, + signatures: [], + }; + + if (!node) { + return result; + } + + if ( + node.type !== NodeType.Function && + node.type !== NodeType.MixinReference + ) { + if ( + !node.parent || + (node.parent.type !== NodeType.Function && + node.parent.type !== NodeType.MixinReference) + ) { + return result; + } + + node = node.parent as Function | MixinReference; + } + + const identifier = node.getIdentifier()!.getText(); + const parameters = node.getArguments().getChildren(); + result.activeParameter = parameters.length; + + const definition = await this.ls.findDefinition( + document, + document.positionAt(node.offset + identifier.length), + ); + + if (definition) { + const symbol = await this.findDefinitionSymbol(definition, identifier); + if (!symbol) return result; + + const allParameters = getParametersFromDetail(symbol.detail); + if (allParameters.length >= (result.activeParameter || 0)) { + const signatureInfo = SignatureInformation.create( + `${identifier}${symbol.detail || "()"}`, + ); + + const sassdoc = applySassDoc(symbol); + + signatureInfo.documentation = { + kind: MarkupKind.Markdown, + value: sassdoc, + }; + + if (symbol.detail) { + signatureInfo.parameters = []; + const parameters = getParametersFromDetail(symbol.detail); + for (const { name } of parameters) { + let documentation; + if (symbol.sassdoc) { + const dollarless = asDollarlessVariable(name); + const paramDoc = symbol.sassdoc.parameter?.find( + (pdoc) => pdoc.name === dollarless, + ); + if (paramDoc) { + documentation = paramDoc.description; + } + } + signatureInfo.parameters.push({ + label: name.trim(), + documentation, + }); + } + } + + result.signatures.push(signatureInfo); + } + } else if (result.signatures.length === 0) { + // if no suggestion, look for built-in + for (const { reference, exports } of Object.values(sassBuiltInModules)) { + for (const [name, { signature, description }] of Object.entries( + exports, + )) { + if (name === identifier) { + // Make sure we don't accidentaly match with CSS functions by checking + // for hints of a module name before the entry. Essentially look for ".". + // We could look for the module names, but that may be aliased away. + // Do an includes-check in case signature har more than one parameter. + const isNamespaced = node.parent?.type === NodeType.Module; + if (!isNamespaced) { + continue; + } + + const signatureInfo = SignatureInformation.create( + `${name}${signature}`, + ); + + signatureInfo.documentation = { + kind: MarkupKind.Markdown, + value: `${description}\n\n[Sass reference](${reference}#${name})`, + }; + + if (signature) { + const params = signature + .replace(/:.+[$)]/g, "") // Remove default values + .replace(/[().]/g, "") // Remove parentheses and ... list indicator + .split(","); + + signatureInfo.parameters = params.map((p) => ({ + label: p.trim(), + })); + } + + result.signatures.push(signatureInfo); + break; + } + } + } + } + + return result; + } +} + +type Parameter = { + name: string; + defaultValue?: string; +}; + +function getParametersFromDetail(detail?: string): Array { + const result: Parameter[] = []; + if (!detail) { + return result; + } + + const parameters = detail.replace(/[()]/g, "").split(","); + for (const param of parameters) { + let name = param; + let defaultValue: string | undefined = undefined; + const defaultValueStart = param.indexOf(":"); + if (defaultValueStart !== -1) { + name = param.substring(0, defaultValueStart); + defaultValue = param.substring(defaultValueStart + 1); + } + + const parameter: Parameter = { + name: name.trim(), + defaultValue: defaultValue?.trim(), + }; + + result.push(parameter); + } + return result; +} diff --git a/packages/language-services/src/features/find-colors.ts b/packages/language-services/src/features/find-colors.ts new file mode 100644 index 00000000..c8ce19f4 --- /dev/null +++ b/packages/language-services/src/features/find-colors.ts @@ -0,0 +1,92 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import ColorDotJS from "colorjs.io"; +import { LanguageFeature } from "../language-feature"; +import { + Color, + ColorInformation, + ColorPresentation, + NodeType, + Range, + TextDocument, + Variable, +} from "../language-services-types"; + +export class FindColors extends LanguageFeature { + async findColors(document: TextDocument): Promise { + const result: ColorInformation[] = []; + + const variables: Variable[] = []; + const stylesheet = this.ls.parseStylesheet(document); + stylesheet.accept((node) => { + if (node.type !== NodeType.VariableName) { + return true; + } + + const parent = node.getParent(); + if ( + parent && + parent.type !== NodeType.VariableDeclaration && + parent.type !== NodeType.FunctionParameter + ) { + variables.push(node as Variable); + } + return true; + }); + + for (const variable of variables) { + const value = await this.findValue( + document, + document.positionAt(variable.offset), + ); + if (value) { + try { + const color = ColorDotJS.parse(value); + const srgba = ColorDotJS.to(color, "srgb"); + const colorInformation: ColorInformation = { + color: { + alpha: srgba.alpha || 1, + red: srgba.coords[0], + green: srgba.coords[1], + blue: srgba.coords[2], + }, + range: { + start: document.positionAt(variable.offset), + end: document.positionAt( + variable.offset + (variable as Variable).getName().length, + ), + }, + }; + result.push(colorInformation); + } catch (e) { + // do nothing + } + } + } + return result; + } + + getColorPresentations( + document: TextDocument, + color: Color, + range: Range, + ): ColorPresentation[] { + const stylesheet = this.ls.parseStylesheet(document); + const node = getNodeAtOffset(stylesheet, document.offsetAt(range.start)); + + // Only suggest alternate presentations for the declaration + // so we don't suggest replacing ex. color: $variable; with color: #ffffff; + if (node && node.type === NodeType.VariableName) { + const parent = node.getParent(); + if (parent && parent.type === NodeType.VariableDeclaration) { + return this.getUpstreamLanguageServer().getColorPresentations( + document, + stylesheet, + color, + range, + ); + } + } + + return []; + } +} diff --git a/packages/language-services/src/features/find-definition.ts b/packages/language-services/src/features/find-definition.ts new file mode 100644 index 00000000..1821cbda --- /dev/null +++ b/packages/language-services/src/features/find-definition.ts @@ -0,0 +1,154 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + Location, + Position, + FunctionParameter, + MixinReference, + Function, + Node, + NodeType, + SymbolKind, + VariableDeclaration, + Variable, +} from "../language-services-types"; +import { asDollarlessVariable } from "../utils/sass"; + +export class FindDefinition extends LanguageFeature { + async findDefinition( + document: TextDocument, + position: Position, + ): Promise { + const stylesheet = this.ls.parseStylesheet(document); + const offset = document.offsetAt(position); + const node = getNodeAtOffset(stylesheet, offset); + if (!node) { + return this.goUpstream(document, position, stylesheet); + } + + // Sometimes we can't tell at position whether an identifier is a Method or a Function + // so we'll need to look for more than one SymbolKind. + let kinds: SymbolKind[] | undefined; + let name: string | undefined; + switch (node.type) { + case NodeType.VariableName: { + const parent = node.getParent(); + if (parent) { + if ( + !(parent instanceof FunctionParameter) && + !(parent instanceof VariableDeclaration) + ) { + name = (node as Variable).getName(); + kinds = [SymbolKind.Variable]; + } + } + break; + } + case NodeType.SelectorPlaceholder: { + name = node.getText(); + kinds = [SymbolKind.Class]; + break; + } + case NodeType.Function: { + const identifier = (node as Function).getIdentifier(); + if (!identifier) break; + + name = identifier.getText(); + kinds = [SymbolKind.Function]; + break; + } + case NodeType.MixinReference: { + const identifier = (node as MixinReference).getIdentifier(); + if (!identifier) break; + + name = identifier.getText(); + kinds = [SymbolKind.Method]; + break; + } + case NodeType.Identifier: { + const parent = node.getParent(); + if (parent && parent.type === NodeType.ForwardVisibility) { + name = node.getText(); + // At this point the identifier can be both a function and a mixin. + kinds = [SymbolKind.Method, SymbolKind.Function]; + } else { + let i = 0; + let n: Node | null = node; + let isMixin = false; + let isFunction = false; + while (n && !isMixin && !isFunction && i !== 2) { + n = n.getParent(); + if (n) { + isMixin = n.type === NodeType.MixinReference; + isFunction = n.type === NodeType.Function; + } + i++; + } + if (n && (isMixin || isFunction)) { + let kind: SymbolKind = SymbolKind.Method; + if (isFunction) { + kind = SymbolKind.Function; + } + name = (n as Function | MixinReference).getName(); + kinds = [kind]; + } + } + break; + } + } + + if (!name || !kinds) { + return this.goUpstream(document, position, stylesheet); + } + + // Traverse the workspace looking for a symbol of kinds.includes(symbol.kind) && name === symbol.name + const result = await this.findInWorkspace( + (document, prefix) => { + const symbols = this.ls.findDocumentSymbols(document); + for (const symbol of symbols) { + if (symbol.kind === SymbolKind.Class) { + // Placeholders are not prefixed the same way other symbols are + if (kinds!.includes(symbol.kind) && symbol.name === name) { + return Location.create(document.uri, symbol.selectionRange); + } + } + + const prefixedSymbol = `${prefix}${asDollarlessVariable(symbol.name)}`; + const prefixedName = asDollarlessVariable(name!); + if (kinds!.includes(symbol.kind) && prefixedSymbol === prefixedName) { + return Location.create(document.uri, symbol.selectionRange); + } + } + }, + document, + { lazy: true }, + ); + + if (result.length !== 0) { + return result[0]; + } + + // If not found, go through the old fashioned way and assume everything is in scope via @import + const symbols = this.ls.findWorkspaceSymbols(name); + for (const symbol of symbols) { + if (kinds.includes(symbol.kind)) { + return symbol.location; + } + } + + return this.goUpstream(document, position, stylesheet); + } + + private goUpstream( + document: TextDocument, + position: Position, + stylesheet: Node, + ): Location | null { + return this.getUpstreamLanguageServer().findDefinition( + document, + position, + stylesheet, + ); + } +} diff --git a/packages/language-services/src/features/find-document-highlights.ts b/packages/language-services/src/features/find-document-highlights.ts new file mode 100644 index 00000000..b5989e90 --- /dev/null +++ b/packages/language-services/src/features/find-document-highlights.ts @@ -0,0 +1,20 @@ +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + Position, + DocumentHighlight, +} from "../language-services-types"; + +export class FindDocumentHighlights extends LanguageFeature { + findDocumentHighlights( + document: TextDocument, + position: Position, + ): DocumentHighlight[] { + const stylesheet = this.ls.parseStylesheet(document); + return this.getUpstreamLanguageServer().findDocumentHighlights( + document, + position, + stylesheet, + ); + } +} diff --git a/packages/language-services/src/features/find-document-links.ts b/packages/language-services/src/features/find-document-links.ts new file mode 100644 index 00000000..5a598397 --- /dev/null +++ b/packages/language-services/src/features/find-document-links.ts @@ -0,0 +1,34 @@ +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + SassDocumentLink, + URI, +} from "../language-services-types"; + +export class FindDocumentLinks extends LanguageFeature { + async findDocumentLinks(document: TextDocument): Promise { + const cached = this.cache.getResolvedLinks(document); + if (cached) return cached; + + const stylesheet = this.ls.parseStylesheet(document); + const links = await this.getUpstreamLanguageServer().findDocumentLinks2( + document, + stylesheet, + this.getDocumentContext(), + ); + for (const link of links) { + if (link.target && !link.target.includes("sass:")) { + // For monorepos, resolve the real path behind a symlink, since multiple links in `node_modules/` can point to the same file. + // Take this initial performance hit to maximise cache hits and provide better results for projects using symlinks. + const realpath = await this.options.fileSystemProvider.realPath( + URI.parse(link.target), + ); + link.target = realpath.toString(); + } + } + + this.cache.putResolvedLinks(document, links); + + return links; + } +} diff --git a/packages/language-services/src/features/find-references.ts b/packages/language-services/src/features/find-references.ts new file mode 100644 index 00000000..c8d5bf7e --- /dev/null +++ b/packages/language-services/src/features/find-references.ts @@ -0,0 +1,553 @@ +import { getNodeAtOffset } from "@somesass/vscode-css-languageservice"; +import { sassBuiltInModules } from "../facts/sass"; +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + SassDocumentSymbol, + Location, + SymbolKind, + Position, + NodeType, + Variable, + MixinReference, + Function, + Range, + URI, + ReferenceContext, + Node, + MixinDeclaration, +} from "../language-services-types"; +import { asDollarlessVariable } from "../utils/sass"; + +type Declaration = { + symbol: SassDocumentSymbol; + document: TextDocument; +}; + +type References = { + declaration: Declaration | null; + references: Reference[]; +}; + +type Reference = { + location: Location; + name: string; + kind: SymbolKind | null; + defaultBehavior: boolean; +}; + +export class FindReferences extends LanguageFeature { + async findReferences( + document: TextDocument, + position: Position, + context: ReferenceContext = { includeDeclaration: true }, + ): Promise { + const references = await this.internalFindReferences( + document, + position, + context, + ); + return references.references.map((r) => r.location); + } + + protected async internalFindReferences( + document: TextDocument, + position: Position, + context: ReferenceContext, + ): Promise { + const references: References = { + declaration: null, + references: [], + }; + + const { declaration, name } = await this.getDeclaration( + document, + position, + context, + ); + + references.declaration = declaration; + + let builtin: [string, string] | null = null; + if (!references.declaration) { + // If we don't have a declaration anywhere we might be dealing with a built-in. + // Check to see if that's the case. + + for (const [module, { exports }] of Object.entries(sassBuiltInModules)) { + for (const [builtinName] of Object.entries(exports)) { + if (builtinName === name) { + builtin = [module.split(":")[1] as string, builtinName]; + } + } + } + } + + // If we have neither a declaration nor a built-in, return an empty result + if (!references.declaration && !builtin) { + return references; + } + + const declarationName = asDollarlessVariable( + builtin ? builtin[1] : references.declaration!.symbol.name, + ); + + const documents = this.cache.documents(); + for (const doc of documents) { + const stylesheet = this.ls.parseStylesheet(doc); + const candidates: Reference[] = []; + + stylesheet.accept((node) => { + switch (node.type) { + case NodeType.VariableName: { + const parent = node?.getParent(); + if (!parent) break; + + if ( + (parent.type !== NodeType.VariableDeclaration || + context.includeDeclaration) && + parent.type !== NodeType.FunctionParameter + ) { + const candidateName = (node as Variable).getName(); + if (!candidateName.includes(declarationName)) break; + + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(node.offset), + doc.positionAt(node.end), + ), + }, + name: candidateName, + kind: SymbolKind.Variable, + defaultBehavior: false, + }); + } + break; + } + + case NodeType.Function: { + const identifier = (node as Function).getIdentifier(); + if (!identifier) break; + + // To avoid collisions with CSS functions, only support built-ins in the module system + if (builtin && node.parent?.type !== NodeType.Module) break; + + const candidateName = identifier.getText(); + if (!candidateName.includes(declarationName)) break; + + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(identifier.offset), + doc.positionAt(identifier.end), + ), + }, + name: candidateName, + kind: SymbolKind.Function, + defaultBehavior: false, + }); + break; + } + + case NodeType.FunctionDeclaration: { + if (!context.includeDeclaration) break; + const identifier = (node as MixinDeclaration).getIdentifier(); + if (!identifier) break; + + const candidateName = identifier.getText(); + if (!candidateName.includes(declarationName)) break; + + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(identifier.offset), + doc.positionAt(identifier.end), + ), + }, + name: candidateName, + kind: SymbolKind.Function, + defaultBehavior: false, + }); + break; + } + + case NodeType.MixinReference: { + const identifier = (node as MixinReference).getIdentifier(); + if (!identifier) break; + + const candidateName = identifier.getText(); + if (!candidateName.includes(declarationName)) break; + + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(identifier.offset), + doc.positionAt(identifier.end), + ), + }, + name: candidateName, + kind: SymbolKind.Method, + defaultBehavior: false, + }); + break; + } + + case NodeType.MixinDeclaration: { + if (!context.includeDeclaration) break; + const identifier = (node as MixinDeclaration).getIdentifier(); + if (!identifier) break; + + const candidateName = identifier.getText(); + if (!candidateName.includes(declarationName)) break; + + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(identifier.offset), + doc.positionAt(identifier.end), + ), + }, + name: candidateName, + kind: SymbolKind.Method, + defaultBehavior: false, + }); + break; + } + + case NodeType.SelectorPlaceholder: { + const candidateName = node.getText(); + if (!candidateName.includes(declarationName)) break; + + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(node.offset), + doc.positionAt(node.end), + ), + }, + name: candidateName, + kind: SymbolKind.Class, + defaultBehavior: false, + }); + break; + } + + case NodeType.Identifier: { + const parent = node?.getParent(); + if (!parent) break; + + if (parent.type === NodeType.ForwardVisibility) { + const candidateName = node.getText(); + if (!candidateName.includes(declarationName)) break; + + // if parent is ForwardVisibility, we can't tell between functions or mixins, so look for both. + const candidateKinds = [SymbolKind.Function, SymbolKind.Method]; + for (const kind of candidateKinds) { + candidates.push({ + location: { + uri: doc.uri, + range: Range.create( + doc.positionAt(node.offset), + doc.positionAt(node.end), + ), + }, + name: candidateName, + kind, + defaultBehavior: false, + }); + } + } + break; + } + } + + return true; + }); + + for (const candidate of candidates) { + if (references.declaration) { + if (candidate.kind !== references.declaration.symbol.kind) continue; + + const candidateIsDeclaration = + candidate.name === references.declaration.symbol.name && + candidate.kind === references.declaration.symbol.kind && + candidate.location.uri === references.declaration.document.uri && + // Only check the start position here, since + // a VariableDeclaration's range is larger than + // a Variable reference's range (which doesn't include the value). + this.isSamePosition( + candidate.location.range.start, + references.declaration.symbol.selectionRange.start, + ); + + if (!context.includeDeclaration && candidateIsDeclaration) { + continue; + } else if (candidateIsDeclaration) { + references.references.push(candidate); + continue; + } + + const candidateDeclaration = await this.ls.findDefinition( + doc, + candidate.location.range.start, + ); + if (candidateDeclaration != null) { + const isSameFile = await this.isSameRealPath( + candidateDeclaration.uri, + references.declaration.document.uri, + ); + + // Only check the start position here, since + // a VariableDeclaration's range is larger than + // a Variable reference's range (which doesn't include the value). + const isSamePosition = this.isSamePosition( + candidateDeclaration.range.start, + references.declaration.symbol.selectionRange.start, + ); + + if (isSameFile && isSamePosition) { + references.references.push(candidate); + continue; + } + } + } + + // If we don't have a reference.definition or candidateDefinition, we might be dealing with a built-in. + // If that's the case, add the reference even without the definition. + if (builtin) { + const builtinName = builtin[1]; + if (builtinName.includes(candidate.name)) { + references.references.push({ + ...candidate, + defaultBehavior: true, + }); + } + } + } + } + + return references; + } + + async getDeclaration( + document: TextDocument, + position: Position, + context: ReferenceContext, + ): Promise<{ + name: string | null; + kind: SymbolKind | null; + declaration: Declaration | null; + }> { + const result: { + name: string | null; + kind: SymbolKind | null; + declaration: Declaration | null; + } = { + name: null, + kind: null, + declaration: null, + }; + + const stylesheet = this.ls.parseStylesheet(document); + const refNode = getNodeAtOffset(stylesheet, document.offsetAt(position)); + if (!refNode) return result; + + switch (refNode.type) { + case NodeType.VariableName: { + const parent = refNode?.getParent(); + if ( + parent && + (parent.type !== NodeType.VariableDeclaration || + context.includeDeclaration) && + parent.type !== NodeType.FunctionParameter + ) { + result.name = (refNode as Variable).getName(); + result.kind = SymbolKind.Variable; + } + break; + } + + case NodeType.Function: { + result.name = (refNode as Function).getName(); + result.kind = SymbolKind.Function; + break; + } + + case NodeType.FunctionDeclaration: { + if (!context.includeDeclaration) break; + result.name = (refNode as Function).getName(); + result.kind = SymbolKind.Function; + break; + } + + case NodeType.MixinReference: { + result.name = (refNode as MixinReference)?.getName(); + result.kind = SymbolKind.Method; + break; + } + + case NodeType.MixinDeclaration: { + if (!context.includeDeclaration) break; + result.name = (refNode as MixinReference).getName(); + result.kind = SymbolKind.Method; + break; + } + + case NodeType.SelectorPlaceholder: { + result.name = refNode?.getText(); + result.kind = SymbolKind.Class; + break; + } + + case NodeType.Identifier: { + let node; + let type: SymbolKind | null = null; + let parent = refNode?.getParent(); + + // For modules, the identifier and function/mixin are sibling nodes. + if (parent && parent.type === NodeType.Module) { + parent = + parent + .getChildren() + .find( + (c) => + c.type === NodeType.Function || + c.type === NodeType.MixinReference, + ) || null; + if (parent) { + node = ( + parent as Function | MixinReference + ).getIdentifier() as Node; + } + } + + if (parent && parent.type === NodeType.ForwardVisibility) { + // At this point the identifier can be both a function and a mixin. + // To figure it out we need to look for the original definition. + const definition = await this.ls.findDefinition(document, position); + if (!definition) break; + + result.name = refNode.getText(); + const definitionSymbol = await this.findDefinitionSymbol( + definition, + result.name, + ); + + if (!definitionSymbol) break; + result.kind = definitionSymbol.kind; + break; + } + + if ( + parent && + (parent.type === NodeType.Function || + (parent.type === NodeType.FunctionDeclaration && + context.includeDeclaration)) + ) { + node = parent; + type = SymbolKind.Function; + } else if ( + parent && + (parent.type === NodeType.MixinReference || + (parent.type === NodeType.MixinDeclaration && + context.includeDeclaration)) + ) { + node = parent; + type = SymbolKind.Method; + } + if (type === null) break; + if (node) { + result.name = (node as Function | MixinReference).getName(); + result.kind = type; + } + break; + } + } + + if (!result.name || !result.kind) return result; + + // Check to see if we have a symbol of name and kind in the current document + const symbols = this.ls.findDocumentSymbols(document); + const definition = symbols.find( + (symbol) => symbol.name === result.name && symbol.kind === result.kind, + ); + if (definition) { + result.declaration = { + symbol: definition, + document, + }; + } else { + // If not, get the definition for the current position + const definition = await this.ls.findDefinition(document, position); + if (definition) { + const document = this.cache.getDocument(definition.uri); + if (document) { + const dollarlessName = asDollarlessVariable(result.name); + const symbols = this.ls.findDocumentSymbols(document); + const definitionSymbol = symbols.find( + (symbol) => + // use includes because of @forward prefixing + dollarlessName.includes(asDollarlessVariable(symbol.name)) && + symbol.kind === result.kind, + ); + if (definitionSymbol) { + result.declaration = { + symbol: definitionSymbol, + document, + }; + } + } + } + } + + return result; + } + + async isSameRealPath( + candidate: string, + definition: string, + ): Promise { + // Checking the file system is expensive, so do the optimistic thing first. + // If the URIs match, we're good. + if (candidate === definition) { + return true; + } + + if (candidate.includes(this.getFileName(definition))) { + try { + const candidateDocument = this.cache.getDocument(candidate); + if (!candidateDocument) { + return false; + } + + const realCandidate = await this.options.fileSystemProvider.realPath( + URI.parse(candidate), + ); + if (!realCandidate) { + return false; + } + + const realDefinition = await this.options.fileSystemProvider.realPath( + URI.parse(definition), + ); + if (!realDefinition) { + return false; + } + + if (realCandidate === realDefinition) { + return true; + } + } catch { + // Guess it really doesn't exist + } + } + + return false; + } +} diff --git a/packages/language-services/src/features/find-symbols.ts b/packages/language-services/src/features/find-symbols.ts new file mode 100644 index 00000000..ee71edd4 --- /dev/null +++ b/packages/language-services/src/features/find-symbols.ts @@ -0,0 +1,87 @@ +import { ParseResult } from "scss-sassdoc-parser"; +import { LanguageFeature } from "../language-feature"; +import { + TextDocument, + SassDocumentSymbol, + SymbolKind, + SymbolInformation, + Location, +} from "../language-services-types"; + +export class FindSymbols extends LanguageFeature { + findDocumentSymbols(document: TextDocument): SassDocumentSymbol[] { + // While not IO-costly like findDocumentLinks, findDocumentSymbols is such a + // hot path that the CPU time it takes to call findDocumentSymbols2 adds up. + const cachedSymbols = this.cache.getCachedSymbols(document); + if (cachedSymbols) return cachedSymbols; + + const stylesheet = this.ls.parseStylesheet(document); + const symbols = this.getUpstreamLanguageServer().findDocumentSymbols2( + document, + stylesheet, + ) as SassDocumentSymbol[]; + + const sassdoc: ParseResult[] = this.cache.getSassdoc(document); + for (const doc of sassdoc) { + switch (doc.context.type) { + case "variable": { + const symbol = symbols.find( + (s) => + s.kind === SymbolKind.Variable && + s.name.replace("$", "") === doc.context.name, + ); + if (symbol) symbol.sassdoc = doc; + break; + } + case "mixin": { + const symbol = symbols.find( + (s) => s.kind === SymbolKind.Method && s.name === doc.context.name, + ); + if (symbol) symbol.sassdoc = doc; + break; + } + case "function": { + const symbol = symbols.find( + (s) => + s.kind === SymbolKind.Function && s.name === doc.context.name, + ); + if (symbol) symbol.sassdoc = doc; + break; + } + case "placeholder": { + const symbol = symbols.find( + (s) => + s.kind === SymbolKind.Class && + s.name.startsWith("%") && + s.name.substring(1) === doc.context.name, + ); + if (symbol) symbol.sassdoc = doc; + break; + } + } + } + + this.cache.putCachedSymbols(document, symbols); + + return symbols; + } + + findWorkspaceSymbols(query?: string): SymbolInformation[] { + const documents = this.cache.documents(); + const result: SymbolInformation[] = []; + for (const document of documents) { + const symbols = this.findDocumentSymbols(document); + for (const symbol of symbols) { + if (query && !symbol.name.includes(query)) { + continue; + } + result.push({ + name: symbol.name, + kind: symbol.kind, + location: Location.create(document.uri, symbol.selectionRange), + }); + } + } + return result; + } +} diff --git a/packages/language-services/src/language-feature.ts b/packages/language-services/src/language-feature.ts new file mode 100644 index 00000000..a4ad5709 --- /dev/null +++ b/packages/language-services/src/language-feature.ts @@ -0,0 +1,363 @@ +import { resolve } from "url"; +import { + getNodeAtOffset, + LanguageService as VSCodeLanguageService, + Scanner, + SCSSScanner, +} from "@somesass/vscode-css-languageservice"; +import { LanguageModelCache } from "./language-model-cache"; +import { + LanguageServiceOptions, + TextDocument, + LanguageService, + LanguageServiceConfiguration, + NodeType, + Range, + SassDocumentSymbol, + Location, + Position, + Variable, + VariableDeclaration, + URI, +} from "./language-services-types"; +import { joinPath } from "./utils/resources"; +import { asDollarlessVariable } from "./utils/sass"; + +export type LanguageFeatureInternal = { + cache: LanguageModelCache; + scssLs: VSCodeLanguageService; +}; + +type FindOptions = { + /** + * Whether to stop searching if the callback returns a truthy response. + * @default false + */ + lazy: boolean; +}; + +const defaultConfiguration: LanguageServiceConfiguration = { + completionSettings: { + triggerPropertyValueCompletion: false, + completePropertyWithSemicolon: false, + suggestAllFromOpenDocument: false, + suggestFromUseOnly: false, + suggestFunctionsInStringContextAfterSymbols: " (+-*%", + suggestionStyle: "all", + }, +}; + +/** + * Base class for features. Provides helpers to do the navigation + * between modules. + */ +export abstract class LanguageFeature { + protected ls; + protected options; + protected configuration: LanguageServiceConfiguration = {}; + + private _internal: LanguageFeatureInternal; + + protected get cache(): LanguageModelCache { + return this._internal.cache; + } + + constructor( + ls: LanguageService, + options: LanguageServiceOptions, + _internal: LanguageFeatureInternal, + ) { + this.ls = ls; + this.options = options; + this._internal = _internal; + } + + configure(configuration: LanguageServiceConfiguration): void { + this.configuration = { + ...defaultConfiguration, + ...configuration, + completionSettings: { + ...defaultConfiguration.completionSettings, + ...configuration.completionSettings, + triggerPropertyValueCompletion: + configuration.completionSettings?.triggerPropertyValueCompletion || + false, + }, + }; + this._internal.scssLs.configure(configuration); + } + + protected getUpstreamLanguageServer(): VSCodeLanguageService { + return this._internal.scssLs; + } + + protected getDocumentContext() { + return { + /** + * @param ref Resolve this path from the context of the document + * @returns The resolved path + */ + resolveReference: (ref: string, base: string) => { + if (ref.startsWith("/") && this.configuration.workspaceRoot) { + return joinPath(this.configuration.workspaceRoot.toString(), ref); + } + try { + return resolve(base, ref); + } catch (e) { + return undefined; + } + }, + }; + } + + /** + * Get the scanner implementation for the document's syntax. + * @param document This document's text will be set as the scanner source + * @param range Optional range passed to {@link TextDocument.getText} + */ + protected getScanner(document: TextDocument, range?: Range): Scanner { + const scanner = new SCSSScanner(); + scanner.ignoreComment = false; + scanner.setSource(document.getText(range)); + return scanner; + } + + /** + * Helper to do some kind of lookup for the import tree of a document. + * Usually used to find the declaration of a symbol in the currently open document, but the callback can do whatever it likes. + * + * @param callback Gets called for each node in the import tree (may happen more than once for the same document). Return undefined if the callback should not add to the results. + * @param initialDocument The starting point, typically the document that gets passed to the language feature function. + * @returns The aggregated results of {@link callback} + */ + protected async findInWorkspace( + callback: ( + document: TextDocument, + prefix: string, + hide: string[], + show: string[], + ) => T | T[] | undefined | Promise, + initialDocument: TextDocument, + options: FindOptions = { lazy: false }, + ): Promise { + return this.internalFindInWorkspace(callback, initialDocument, options); + } + + private async internalFindInWorkspace( + callback: ( + document: TextDocument, + prefix: string, + hide: string[], + show: string[], + ) => T | T[] | undefined | Promise, + initialDocument: TextDocument, + options: FindOptions, + currentDocument: TextDocument = initialDocument, + accumulatedPrefix = "", + hide: string[] = [], + show: string[] = [], + visited = new Set(), + depth = 0, + ): Promise { + if (visited.has(currentDocument.uri)) return []; + + const callbackResult = await callback( + currentDocument, + accumulatedPrefix, + hide, + show, + ); + + visited.add(currentDocument.uri); + + if (options.lazy && callbackResult) + return Array.isArray(callbackResult) ? callbackResult : [callbackResult]; + + const allLinks = await this.ls.findDocumentLinks(currentDocument); + + // Filter out links we want to follow + const links = allLinks.filter((link) => { + if (link.type === NodeType.Use) { + // Don't follow uses beyond the first, since symbols from those aren't available to us anyway + return depth === 0; + } + if (link.type === NodeType.Import) { + // Don't follow imports, since the whole point here is to use the new module system + return false; + } + return true; + }); + + if (links.length === 0) { + if (typeof callbackResult === "undefined") { + return []; + } + return Array.isArray(callbackResult) ? callbackResult : [callbackResult]; + } + + let result: T[] = []; + for (const link of links) { + if (!link.target || link.target === currentDocument.uri) { + continue; + } + + let next = this.cache.getDocument(link.target!); + if (!next) { + try { + // If the linked document hasn't been parsed yet, create a TextDocument + const content = await this.options.fileSystemProvider.readFile( + URI.parse(link.target), + ); + const originalExt = link.target.slice( + Math.max(0, link.target.lastIndexOf(".") + 1), + ); + next = TextDocument.create(link.target, originalExt, 1, content); + this.ls.parseStylesheet(next); // add it to the cache + } catch { + continue; + } + } + + let prefix = accumulatedPrefix; + if (link.type === NodeType.Forward) { + if (link.as) { + prefix += link.as; + } + if (link.hide) { + hide.push(...link.hide); + } + if (link.show) { + show.push(...link.show); + } + } + + const linkResult = await this.internalFindInWorkspace( + callback, + initialDocument, + options, + next, + prefix, + hide, + show, + visited, + depth + 1, + ); + result = result.concat(linkResult); + } + + return result; + } + + protected getVariableValue( + document: TextDocument, + variable: SassDocumentSymbol, + ): string | null { + const offset = document.offsetAt(variable.selectionRange.start); + const stylesheet = this.ls.parseStylesheet(document); + const node = getNodeAtOffset(stylesheet, offset); + if (node === null) { + return null; + } + const parent = node.getParent(); + if (!parent) { + return null; + } + if (parent instanceof VariableDeclaration) { + return parent.getValue()?.getText() || null; + } + return null; + } + + protected isSamePosition(a: Position, b: Position): boolean { + return a.line === b.line && a.character === b.character; + } + + protected async findDefinitionSymbol( + definition: Location, + name: string, + ): Promise { + const definitionDocument = this.cache.getDocument(definition.uri); + if (definitionDocument) { + const dollarlessName = asDollarlessVariable(name); + const symbols = this.ls.findDocumentSymbols(definitionDocument); + for (const symbol of symbols) { + if ( + dollarlessName.includes(asDollarlessVariable(symbol.name)) && + this.isSamePosition( + definition.range.start, + symbol.selectionRange.start, + ) + ) { + return symbol; + } + } + } + + return null; + } + + protected getFileName(uri: string): string { + const lastSlash = uri.lastIndexOf("/"); + return lastSlash === -1 ? uri : uri.slice(Math.max(0, lastSlash + 1)); + } + + /** + * Looks at {@link position} for a {@link VariableDeclaration} and returns its value as a string (or null if no value was found). + * If the value is a reference to another variable this method will find that variable's definition and look for the value there instead. + * + * If the value is not found in 20 lookups, assumes a circular reference and returns null. + */ + async findValue( + document: TextDocument, + position: Position, + ): Promise { + return this.internalFindValue(document, position); + } + + private async internalFindValue( + document: TextDocument, + position: Position, + depth = 0, + ): Promise { + const MAX_VARIABLE_REFERENCE_LOOKUPS = 20; + if (depth > MAX_VARIABLE_REFERENCE_LOOKUPS) { + return null; + } + const offset = document.offsetAt(position); + const stylesheet = this.ls.parseStylesheet(document); + + const variable = getNodeAtOffset(stylesheet, offset); + if (!(variable instanceof Variable)) { + return null; + } + + const parent = variable.getParent(); + if (parent instanceof VariableDeclaration) { + return parent.getValue()?.getText() || null; + } + + const valueString = variable.getText(); + const dollarIndex = valueString.indexOf("$"); + if (dollarIndex !== -1) { + // If the variable at position references another variable, + // find that variable's definition and look for the real value + // there instead. + const definition = await this.ls.findDefinition(document, position); + if (definition) { + const newDocument = this.cache.getDocument(definition.uri); + if (!newDocument) { + return null; + } + return await this.internalFindValue( + newDocument, + definition.range.start, + depth + 1, + ); + } else { + return null; + } + } else { + return valueString; + } + } +} diff --git a/packages/language-services/src/language-model-cache.ts b/packages/language-services/src/language-model-cache.ts new file mode 100644 index 00000000..44add4c6 --- /dev/null +++ b/packages/language-services/src/language-model-cache.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { LanguageService as VSCodeLanguageService } from "@somesass/vscode-css-languageservice"; +import { ParseResult, parseSync } from "scss-sassdoc-parser"; +import { + TextDocument, + Stylesheet, + LanguageModelCacheOptions, + Node, + SassDocumentLink, + SassDocumentSymbol, +} from "./language-services-types"; + +type LanguageModels = { + [uri: string]: { + version: number; + languageId: string; + cTime: number; + languageModel: Stylesheet; + document: TextDocument; + sassdoc?: ParseResult[]; + symbols?: SassDocumentSymbol[]; + links?: SassDocumentLink[]; + }; +}; + +const defaultCacheEvictInterval = 0; // default off to not leave an interval running in case of unit tests + +export class LanguageModelCache { + #languageModels: LanguageModels = {}; + #nModels = 0; + #options: LanguageModelCacheOptions & { scssLs: VSCodeLanguageService }; + #cleanupInterval: NodeJS.Timeout | undefined = undefined; + + constructor( + options: LanguageModelCacheOptions & { scssLs: VSCodeLanguageService }, + ) { + this.#options = { + maxEntries: 10_000, + cleanupIntervalTimeInSeconds: defaultCacheEvictInterval, + ...options, + }; + + const intervalTime = + typeof this.#options.cleanupIntervalTimeInSeconds === "undefined" + ? defaultCacheEvictInterval + : this.#options.cleanupIntervalTimeInSeconds; + if (intervalTime > 0) { + this.#cleanupInterval = setInterval(() => { + const cutoffTime = Date.now() - intervalTime * 1000; + const uris = Object.keys(this.#languageModels); + for (const uri of uris) { + const languageModelInfo = this.#languageModels[uri]; + if (languageModelInfo.cTime < cutoffTime) { + delete this.#languageModels[uri]; + this.#nModels--; + } + } + }, intervalTime * 1000); + } + } + + get(document: TextDocument): Stylesheet { + const version = document.version; + const languageId = document.languageId; + const languageModelInfo = this.#languageModels[document.uri]; + if ( + languageModelInfo && + languageModelInfo.version === version && + languageModelInfo.languageId === languageId + ) { + languageModelInfo.cTime = Date.now(); + return languageModelInfo.languageModel; + } + const languageModel = this.#options.scssLs.parseStylesheet( + document, + ) as Node; + let sassdoc: ParseResult[] = []; + try { + const text = document.getText(); + sassdoc = parseSync(text); + } catch { + // do nothing + } + this.#languageModels[document.uri] = { + languageModel, + version, + languageId, + cTime: Date.now(), + document, + sassdoc, + links: undefined, + }; + if (!languageModelInfo) { + this.#nModels++; + } + + if (this.#nModels === this.#options.maxEntries) { + let oldestTime = Number.MAX_VALUE; + let oldestUri: string | null = null; + for (const uri in this.#languageModels) { + const languageModelInfo = this.#languageModels[uri]; + if (languageModelInfo.cTime < oldestTime) { + oldestUri = uri; + oldestTime = languageModelInfo.cTime; + } + } + if (oldestUri) { + delete this.#languageModels[oldestUri]; + this.#nModels--; + } + } + return languageModel; + } + + getDocument(uri: string): TextDocument | undefined { + return this.#languageModels[uri]?.document; + } + + getSassdoc(document: TextDocument): ParseResult[] { + return this.#languageModels[document.uri]?.sassdoc || []; + } + + documents(): TextDocument[] { + return Object.values(this.#languageModels).map((cached) => cached.document); + } + + has(uri: string) { + return typeof this.#languageModels[uri] !== "undefined"; + } + + putResolvedLinks(document: TextDocument, links: SassDocumentLink[]): void { + if (this.has(document.uri)) { + this.#languageModels[document.uri].links = links; + } + } + + getResolvedLinks(document: TextDocument): SassDocumentLink[] | undefined { + if (this.has(document.uri)) { + return this.#languageModels[document.uri].links; + } + } + + putCachedSymbols( + document: TextDocument, + symbols: SassDocumentSymbol[], + ): void { + if (this.has(document.uri)) { + this.#languageModels[document.uri].symbols = symbols; + } + } + + getCachedSymbols(document: TextDocument): SassDocumentSymbol[] | undefined { + if (this.has(document.uri)) { + return this.#languageModels[document.uri].symbols; + } + } + + onDocumentChanged(document: TextDocument) { + const version = document.version; + const languageId = document.languageId; + const languageModel = this.#options.scssLs.parseStylesheet( + document, + ) as Node; + let sassdoc: ParseResult[] = []; + try { + const text = document.getText(); + sassdoc = parseSync(text); + } catch { + // do nothing + } + this.#languageModels[document.uri] = { + languageModel, + version, + languageId, + cTime: Date.now(), + document, + sassdoc, + symbols: undefined, + links: undefined, + }; + } + + onDocumentRemoved(document: TextDocument | string) { + // @ts-expect-error That's what I'm counting on + const uri = document.uri || document; + if (this.#languageModels[uri]) { + delete this.#languageModels[uri]; + this.#nModels--; + } + } + + clearCache() { + if (typeof this.#cleanupInterval !== "undefined") { + clearInterval(this.#cleanupInterval); + this.#cleanupInterval = undefined; + } + this.#languageModels = {}; + this.#nModels = 0; + } +} diff --git a/packages/language-services/src/language-services-types.ts b/packages/language-services/src/language-services-types.ts new file mode 100644 index 00000000..a75a0b28 --- /dev/null +++ b/packages/language-services/src/language-services-types.ts @@ -0,0 +1,433 @@ +import { + Node, + NodeType, + FunctionDeclaration, + Function, + FunctionParameter, + MixinReference, + MixinDeclaration, + Variable, + VariableDeclaration, + Identifier, + Declaration, + ForStatement, + EachStatement, + Import, + Use, + Forward, + ForwardVisibility, + ExtendsReference, + Module, + IToken, + TokenType, + Marker, + CompletionSettings as VSCodeCompletionSettings, +} from "@somesass/vscode-css-languageservice"; +import type { ParseResult } from "scss-sassdoc-parser"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { + Range, + Position, + MarkupKind, + Color, + ColorInformation, + ColorPresentation, + SignatureHelp, + SignatureInformation, + ReferenceContext, + Diagnostic, + DiagnosticTag, + DiagnosticSeverity, + CompletionItem, + CompletionItemKind, + CompletionList, + CompletionItemTag, + InsertTextFormat, + SymbolInformation, + SymbolKind, + DocumentSymbol, + Location, + Hover, + CodeActionContext, + CodeAction, + DocumentHighlight, + DocumentLink, + WorkspaceEdit, + TextEdit, + CodeActionKind, + TextDocumentEdit, + VersionedTextDocumentIdentifier, +} from "vscode-languageserver-types"; +import { URI, Utils } from "vscode-uri"; + +export interface SassDocumentLink extends DocumentLink { + /** + * The namespace of the module. Either equal to {@link as} or derived from {@link target}. + * + * | Link | Value | + * | ------------------ | ----------- | + * | `"./colors"` | `"colors"` | + * | `"./colors" as c` | `"c"` | + * | `"./colors" as *` | `undefined` | + * | `"./_colors"` | `"colors"` | + * | `"./_colors.scss"` | `"colors"` | + * + * @see https://sass-lang.com/documentation/at-rules/use/#choosing-a-namespace + */ + namespace?: string; + /** + * | Link | Value | + * | ---------------------------- | ----------- | + * | `@use "./colors"` | `undefined` | + * | `@use "./colors" as c` | `"c"` | + * | `@use "./colors" as *` | `"*"` | + * | `@forward "./colors"` | `undefined` | + * | `@forward "./colors" as c-*` | `"c"` | + * + * @see https://sass-lang.com/documentation/at-rules/use/#choosing-a-namespace + * @see https://sass-lang.com/documentation/at-rules/forward/#adding-a-prefix + */ + as?: string; + /** + * @see https://sass-lang.com/documentation/at-rules/forward/#controlling-visibility + */ + hide?: string[]; + /** + * @see https://sass-lang.com/documentation/at-rules/forward/#controlling-visibility + */ + show?: string[]; + type?: NodeType; +} + +/** + * The root of the abstract syntax tree. + */ +export type Stylesheet = Node; + +export interface SassDocumentSymbol extends DocumentSymbol { + sassdoc?: ParseResult; + children?: SassDocumentSymbol[]; +} + +export interface LanguageService { + /** + * Clears all cached documents, forcing everything to be reparsed the next time a feature is used. + */ + clearCache(): void; + /** + * You may want to use this to set the workspace root. + * @param settings {@link LanguageServiceConfiguration} + * + * @example + * ```js + * languageService.configure({ + * workspaceRoot: URI.parse(this.workspace), + * }); + * ``` + */ + configure(settings: LanguageServiceConfiguration): void; + doComplete( + document: TextDocument, + position: Position, + ): Promise; + doDiagnostics(document: TextDocument): Promise; + doHover(document: TextDocument, position: Position): Promise; + /** + * Called after a {@link prepareRename} to perform the actual renaming. + */ + doRename( + document: TextDocument, + position: Position, + newName: string, + ): Promise; + doSignatureHelp( + document: TextDocument, + position: Position, + ): Promise; + findColors(document: TextDocument): Promise; + findDefinition( + document: TextDocument, + position: Position, + ): Promise; + findDocumentHighlights( + document: TextDocument, + position: Position, + ): DocumentHighlight[]; + findDocumentLinks(document: TextDocument): Promise; + findDocumentSymbols(document: TextDocument): SassDocumentSymbol[]; + findReferences( + document: TextDocument, + position: Position, + context?: ReferenceContext, + ): Promise; + findWorkspaceSymbols(query?: string): SymbolInformation[]; + getColorPresentations( + document: TextDocument, + color: Color, + range: Range, + ): ColorPresentation[]; + getCodeActions( + document: TextDocument, + range: Range, + context?: CodeActionContext, + ): Promise; + hasCached(uri: URI): boolean; + /** + * Utility function to reparse an updated document. + * Like {@link LanguageService.parseStylesheet}, but returns nothing. + */ + onDocumentChanged(document: TextDocument): void; + /** + * Cleans up the document from the internal cache. + * @param {TextDocument | string} document Either the document itself or {@link TextDocument.uri} + */ + onDocumentRemoved(document: TextDocument | string): void; + /** + * Called internally by the other functions to get a cached AST of the document, or parse it if none exists. + * You typically won't use this directly, but you can if you need access to the raw AST for the document. + */ + parseStylesheet(document: TextDocument): Stylesheet; + /** + * Step one of a rename process, followed by {@link doRename}. + */ + prepareRename( + document: TextDocument, + position: Position, + ): Promise< + null | { defaultBehavior: boolean } | { range: Range; placeholder: string } + >; +} + +export type Rename = + | { range: Range; placeholder: string } + | { defaultBehavior: boolean }; + +export interface LanguageServiceConfiguration { + completionSettings?: CompletionSettings; + editorSettings?: EditorSettings; + /** + * Configure custom aliases that the link resolution should resolve. + * + * @example + * ```js + * importAliases: { + * // \@import "@SassStylesheet" would resolve to /src/assets/style.sass + * "@SassStylesheet": "/src/assets/styles.sass", + * } + * ``` + */ + importAliases?: AliasSettings; + workspaceRoot?: URI; +} + +export interface CompletionSettings extends Partial { + suggestAllFromOpenDocument?: boolean; + /** + * Mixins with `@content` SassDoc annotations and `%placeholders` get two suggestions by default: + * - One without `{ }`. + * - One _with_ `{ }`. This one creates a new block, and moves the cursor inside the block. + * + * If you find this noisy, you can control which suggestions you would like to see: + * - All suggestions (default). + * - No brackets. + * - Only brackets. This still includes other suggestions, where there are no brackets to begin with. + * + * @default "all" + */ + suggestionStyle?: "all" | "nobracket" | "bracket"; + /** + * Recommended if you don't rely on `@import`. With this setting turned on, + * Some Sass will only suggest variables, mixins and functions from the + * namespaces that are in use in the open document. + */ + suggestFromUseOnly?: boolean; + /** + * Suggest functions after the specified symbols when in a string context. + * For example, if you add the `/` symbol to this setting, then `background: url(images/he|)` + * could suggest a `hello()` function (`|` in this case indicates cursor position). + * + * @default " (+-*%" + */ + suggestFunctionsInStringContextAfterSymbols?: string; +} + +export interface EditorSettings { + /** + * Insert spaces rather than tabs. + */ + insertSpaces?: boolean; + /** + * If {@link insertSpaces} is true this option determines the number of space characters is inserted per indent level. + */ + indentSize?: number; + /** + * An older editor setting in VS Code. If both this and {@link indentSize} is set, only `indentSize` will be used. + */ + tabSize?: number; +} + +export interface AliasSettings { + [key: string]: string; +} + +export interface ClientCapabilities { + textDocument?: { + completion?: { + completionItem?: { + documentationFormat?: MarkupKind[]; + }; + }; + hover?: { + contentFormat?: MarkupKind[]; + }; + }; +} + +export namespace ClientCapabilities { + export const LATEST: ClientCapabilities = { + textDocument: { + completion: { + completionItem: { + documentationFormat: [MarkupKind.Markdown, MarkupKind.PlainText], + }, + }, + hover: { + contentFormat: [MarkupKind.Markdown, MarkupKind.PlainText], + }, + }, + }; +} + +export interface LanguageServiceOptions { + clientCapabilities: ClientCapabilities; + /** + * Abstract file system access away from the service to support + * both direct file system access and browser file system access + * via the LSP client. + * + * Used for dynamic link resolving, path completion, etc. + */ + fileSystemProvider: FileSystemProvider; + languageModelCache?: LanguageModelCacheOptions; +} + +export type LanguageModelCacheOptions = { + /** + * @default 360 - five minutes + */ + cleanupIntervalTimeInSeconds?: number; + /** + * @default 10_000 + */ + maxEntries?: number; +}; + +export enum FileType { + Unknown = 0, + File = 1, + Directory = 2, + SymbolicLink = 64, +} + +export interface FileStat { + type: FileType; + /** + * The creation timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + ctime: number; + /** + * The modification timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + mtime: number; + /** + * The size in bytes. + */ + size: number; +} + +/** + * Abstract file system access away from the service to support + * both direct file system access and browser file system access + * via the LSP client. + * + * Used for dynamic link resolving, path completion, etc. + */ +export interface FileSystemProvider { + exists(uri: URI): Promise; + /** + * Finds files in the workspace. + * @param include Glob pattern to search for + * @param exclude Glob pattern or patterns to exclude + */ + findFiles( + include: string, + exclude?: string | string[] | null, + ): Promise; + readFile(uri: URI, encoding?: BufferEncoding): Promise; + readDirectory(uri: URI): Promise<[string, FileType][]>; + stat(uri: URI): Promise; + /** + * For monorepos, resolve the actual location on disk rather than the URL to the symlink. + * @param uri The path to resolve + */ + realPath(uri: URI): Promise; +} + +export { + URI, + Utils, + TextDocument, + Range, + Position, + ReferenceContext, + MarkupKind, + Color, + ColorInformation, + ColorPresentation, + Diagnostic, + DiagnosticTag, + DiagnosticSeverity, + CompletionItem, + CompletionItemKind, + CompletionList, + CompletionItemTag, + InsertTextFormat, + SymbolInformation, + SymbolKind, + DocumentSymbol, + Location, + Hover, + SignatureHelp, + CodeActionContext, + CodeAction, + DocumentHighlight, + DocumentLink, + WorkspaceEdit, + TextEdit, + CodeActionKind, + TextDocumentEdit, + VersionedTextDocumentIdentifier, + Node, + NodeType, + VariableDeclaration, + FunctionDeclaration, + FunctionParameter, + Function, + MixinReference, + MixinDeclaration, + Variable, + Identifier, + Declaration, + ForStatement, + EachStatement, + Import, + Use, + Forward, + ForwardVisibility, + SignatureInformation, + ExtendsReference, + TokenType, + IToken, + Module, + Marker, +}; diff --git a/packages/language-services/src/language-services.ts b/packages/language-services/src/language-services.ts new file mode 100644 index 00000000..8ed28261 --- /dev/null +++ b/packages/language-services/src/language-services.ts @@ -0,0 +1,194 @@ +import { getSCSSLanguageService } from "@somesass/vscode-css-languageservice"; +import { CodeActions } from "./features/code-actions"; +import { DoComplete } from "./features/do-complete"; +import { DoDiagnostics } from "./features/do-diagnostics"; +import { DoHover } from "./features/do-hover"; +import { DoRename } from "./features/do-rename"; +import { DoSignatureHelp } from "./features/do-signature-help"; +import { FindColors } from "./features/find-colors"; +import { FindDefinition } from "./features/find-definition"; +import { FindDocumentHighlights } from "./features/find-document-highlights"; +import { FindDocumentLinks } from "./features/find-document-links"; +import { FindReferences } from "./features/find-references"; +import { FindSymbols } from "./features/find-symbols"; +import { LanguageModelCache as LanguageServerCache } from "./language-model-cache"; +import { + CodeActionContext, + LanguageService, + LanguageServiceConfiguration, + LanguageServiceOptions, + Position, + TextDocument, + FileSystemProvider, + FileStat, + FileType, + Color, + Range, + ReferenceContext, + URI, +} from "./language-services-types"; +import { mapFsProviders } from "./utils/fs-provider"; + +export { LanguageService, FileStat, FileSystemProvider, FileType }; + +export function getLanguageService( + options: LanguageServiceOptions, +): LanguageService { + return new LanguageServiceImpl(options); +} + +class LanguageServiceImpl implements LanguageService { + #cache: LanguageServerCache; + #codeActions: CodeActions; + #doComplete: DoComplete; + #doDiagnostics: DoDiagnostics; + #doHover: DoHover; + #doRename: DoRename; + #doSignatureHelp: DoSignatureHelp; + #findColors: FindColors; + #findDefinition: FindDefinition; + #findDocumentHighlights: FindDocumentHighlights; + #findDocumentLinks: FindDocumentLinks; + #findReferences: FindReferences; + #findSymbols: FindSymbols; + + constructor(options: LanguageServiceOptions) { + const scssLs = getSCSSLanguageService({ + clientCapabilities: options.clientCapabilities, + fileSystemProvider: mapFsProviders(options.fileSystemProvider), + }); + + const cache = new LanguageServerCache({ + scssLs, + ...options.languageModelCache, + }); + this.#cache = cache; + this.#codeActions = new CodeActions(this, options, { scssLs, cache }); + this.#doComplete = new DoComplete(this, options, { scssLs, cache }); + this.#doDiagnostics = new DoDiagnostics(this, options, { scssLs, cache }); + this.#doHover = new DoHover(this, options, { scssLs, cache }); + this.#doRename = new DoRename(this, options, { scssLs, cache }); + this.#doSignatureHelp = new DoSignatureHelp(this, options, { + scssLs, + cache, + }); + this.#findColors = new FindColors(this, options, { scssLs, cache }); + this.#findDefinition = new FindDefinition(this, options, { scssLs, cache }); + this.#findDocumentHighlights = new FindDocumentHighlights(this, options, { + scssLs, + cache, + }); + this.#findDocumentLinks = new FindDocumentLinks(this, options, { + scssLs, + cache, + }); + this.#findReferences = new FindReferences(this, options, { scssLs, cache }); + this.#findSymbols = new FindSymbols(this, options, { scssLs, cache }); + } + + configure(configuration: LanguageServiceConfiguration): void { + this.#codeActions.configure(configuration); + this.#doComplete.configure(configuration); + this.#doDiagnostics.configure(configuration); + this.#doHover.configure(configuration); + this.#doRename.configure(configuration); + this.#doSignatureHelp.configure(configuration); + this.#findColors.configure(configuration); + this.#findDefinition.configure(configuration); + this.#findDocumentHighlights.configure(configuration); + this.#findDocumentLinks.configure(configuration); + this.#findReferences.configure(configuration); + this.#findSymbols.configure(configuration); + } + + parseStylesheet(document: TextDocument) { + return this.#cache.get(document); + } + + doComplete(document: TextDocument, position: Position) { + return this.#doComplete.doComplete(document, position); + } + + doDiagnostics(document: TextDocument) { + return this.#doDiagnostics.doDiagnostics(document); + } + + doHover(document: TextDocument, position: Position) { + return this.#doHover.doHover(document, position); + } + + doRename(document: TextDocument, position: Position, newName: string) { + return this.#doRename.doRename(document, position, newName); + } + + doSignatureHelp(document: TextDocument, position: Position) { + return this.#doSignatureHelp.doSignatureHelp(document, position); + } + + findColors(document: TextDocument) { + return this.#findColors.findColors(document); + } + + findDefinition(document: TextDocument, position: Position) { + return this.#findDefinition.findDefinition(document, position); + } + + findDocumentHighlights(document: TextDocument, position: Position) { + return this.#findDocumentHighlights.findDocumentHighlights( + document, + position, + ); + } + + async findDocumentLinks(document: TextDocument) { + return this.#findDocumentLinks.findDocumentLinks(document); + } + + findDocumentSymbols(document: TextDocument) { + return this.#findSymbols.findDocumentSymbols(document); + } + + async findReferences( + document: TextDocument, + position: Position, + context?: ReferenceContext, + ) { + return this.#findReferences.findReferences(document, position, context); + } + + findWorkspaceSymbols(query?: string) { + return this.#findSymbols.findWorkspaceSymbols(query); + } + + hasCached(uri: URI): boolean { + return this.#cache.has(uri.toString()); + } + + getColorPresentations(document: TextDocument, color: Color, range: Range) { + return this.#findColors.getColorPresentations(document, color, range); + } + + getCodeActions( + document: TextDocument, + range: Range, + context?: CodeActionContext, + ) { + return this.#codeActions.getCodeActions(document, range, context); + } + + onDocumentChanged(document: TextDocument) { + return this.#cache.onDocumentChanged(document); + } + + onDocumentRemoved(document: TextDocument | string) { + this.#cache.onDocumentRemoved(document); + } + + prepareRename(document: TextDocument, position: Position) { + return this.#doRename.prepareRename(document, position); + } + + clearCache() { + this.#cache.clearCache(); + } +} diff --git a/packages/language-services/src/utils/arrays.ts b/packages/language-services/src/utils/arrays.ts new file mode 100644 index 00000000..8c2ac3a3 --- /dev/null +++ b/packages/language-services/src/utils/arrays.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Takes a sorted array and a function p. The array is sorted in such a way that all elements where p(x) is false + * are located before all elements where p(x) is true. + * @returns the least x for which p(x) is true or array.length if no element fullfills the given function. + */ +export function findFirst(array: T[], p: (x: T) => boolean): number { + let low = 0, + high = array.length; + if (high === 0) { + return 0; // no children + } + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (p(array[mid])) { + high = mid; + } else { + low = mid + 1; + } + } + return low; +} + +export function includes(array: T[], item: T): boolean { + return array.indexOf(item) !== -1; +} + +export function union(...arrays: T[][]): T[] { + const result: T[] = []; + for (const array of arrays) { + for (const item of array) { + if (!includes(result, item)) { + result.push(item); + } + } + } + return result; +} diff --git a/packages/language-services/src/utils/fs-provider.ts b/packages/language-services/src/utils/fs-provider.ts new file mode 100644 index 00000000..40149bc4 --- /dev/null +++ b/packages/language-services/src/utils/fs-provider.ts @@ -0,0 +1,37 @@ +import type { FileSystemProvider as CSSFileSystemProvider } from "@somesass/vscode-css-languageservice"; +import { FileType, FileSystemProvider, URI } from "../language-services-types"; + +export function mapFsProviders( + ours: FileSystemProvider, +): CSSFileSystemProvider { + const theirs: CSSFileSystemProvider = { + async stat(uri: string) { + try { + const result = await ours.stat(URI.parse(uri)); + return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + return { + type: FileType.Unknown, + ctime: -1, + mtime: -1, + size: -1, + }; + } + }, + async readDirectory(uri: string) { + const dir = await ours.readDirectory(URI.parse(uri)); + const result: [string, FileType][] = dir.map(([uri, info]) => [ + uri, + info, + ]); + return result; + }, + getContent(uri, encoding) { + return ours.readFile(URI.parse(uri), encoding); + }, + }; + return theirs; +} diff --git a/packages/language-services/src/utils/objects.ts b/packages/language-services/src/utils/objects.ts new file mode 100644 index 00000000..042b750b --- /dev/null +++ b/packages/language-services/src/utils/objects.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function values(obj: { [s: string]: T }): T[] { + return Object.keys(obj).map((key) => obj[key]); +} + +export function isDefined(obj: T | undefined): obj is T { + return typeof obj !== "undefined"; +} diff --git a/packages/language-services/src/utils/resources.ts b/packages/language-services/src/utils/resources.ts new file mode 100644 index 00000000..0a43147c --- /dev/null +++ b/packages/language-services/src/utils/resources.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { URI, Utils } from "../language-services-types"; + +export function dirname(uriString: string): string { + return Utils.dirname(URI.parse(uriString)).toString(true); +} + +export function joinPath(uriString: string, ...paths: string[]): string { + return Utils.joinPath(URI.parse(uriString), ...paths).toString(true); +} diff --git a/packages/language-services/src/utils/sass.ts b/packages/language-services/src/utils/sass.ts new file mode 100644 index 00000000..78c76291 --- /dev/null +++ b/packages/language-services/src/utils/sass.ts @@ -0,0 +1,4 @@ +/** Strips the dollar prefix off a variable name */ +export function asDollarlessVariable(variable: string): string { + return variable.replace(/^\$/, ""); +} diff --git a/packages/language-server/src/utils/sassdoc.ts b/packages/language-services/src/utils/sassdoc.ts similarity index 96% rename from packages/language-server/src/utils/sassdoc.ts rename to packages/language-services/src/utils/sassdoc.ts index 5689e2a8..cae98f0b 100644 --- a/packages/language-server/src/utils/sassdoc.ts +++ b/packages/language-services/src/utils/sassdoc.ts @@ -1,6 +1,6 @@ -import type { ScssSymbol } from "../parser"; +import { SassDocumentSymbol } from "../language-services-types"; -export function applySassDoc(symbol: ScssSymbol): string { +export function applySassDoc(symbol: SassDocumentSymbol): string { if (!symbol.sassdoc) { return ""; } diff --git a/packages/language-services/src/utils/strings.ts b/packages/language-services/src/utils/strings.ts new file mode 100644 index 00000000..d188e0ab --- /dev/null +++ b/packages/language-services/src/utils/strings.ts @@ -0,0 +1,110 @@ +/* eslint-disable prefer-const */ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export function startsWith(haystack: string, needle: string): boolean { + if (haystack.length < needle.length) { + return false; + } + + for (let i = 0; i < needle.length; i++) { + if (haystack[i] !== needle[i]) { + return false; + } + } + + return true; +} + +/** + * Determines if haystack ends with needle. + */ +export function endsWith(haystack: string, needle: string): boolean { + let diff = haystack.length - needle.length; + if (diff > 0) { + return haystack.lastIndexOf(needle) === diff; + } else if (diff === 0) { + return haystack === needle; + } else { + return false; + } +} + +/** + * Computes the difference score for two strings. More similar strings have a higher score. + * We use largest common subsequence dynamic programming approach but penalize in the end for length differences. + * Strings that have a large length difference will get a bad default score 0. + * Complexity - both time and space O(first.length * second.length) + * Dynamic programming LCS computation http://en.wikipedia.org/wiki/Longest_common_subsequence_problem + * + * @param first a string + * @param second a string + */ +export function difference( + first: string, + second: string, + maxLenDelta: number = 4, +): number { + let lengthDifference = Math.abs(first.length - second.length); + // We only compute score if length of the currentWord and length of entry.name are similar. + if (lengthDifference > maxLenDelta) { + return 0; + } + // Initialize LCS (largest common subsequence) matrix. + let LCS: number[][] = []; + let zeroArray: number[] = []; + let i: number, j: number; + for (i = 0; i < second.length + 1; ++i) { + zeroArray.push(0); + } + for (i = 0; i < first.length + 1; ++i) { + LCS.push(zeroArray); + } + for (i = 1; i < first.length + 1; ++i) { + for (j = 1; j < second.length + 1; ++j) { + if (first[i - 1] === second[j - 1]) { + LCS[i][j] = LCS[i - 1][j - 1] + 1; + } else { + LCS[i][j] = Math.max(LCS[i - 1][j], LCS[i][j - 1]); + } + } + } + return LCS[first.length][second.length] - Math.sqrt(lengthDifference); +} + +/** + * Limit of string length. + */ +export function getLimitedString(str: string, ellipsis = true): string { + if (!str) { + return ""; + } + if (str.length < 140) { + return str; + } + return str.slice(0, 140) + (ellipsis ? "\u2026" : ""); +} + +/** + * Limit of string length. + */ +export function trim(str: string, regexp: RegExp): string { + const m = regexp.exec(str); + if (m && m[0].length) { + return str.substr(0, str.length - m[0].length); + } + return str; +} + +export function repeat(value: string, count: number) { + let s = ""; + while (count > 0) { + if ((count & 1) === 1) { + s += value; + } + value += value; + count = count >>> 1; + } + return s; +} diff --git a/packages/language-services/src/utils/test-helpers.ts b/packages/language-services/src/utils/test-helpers.ts new file mode 100644 index 00000000..aa9d7c3d --- /dev/null +++ b/packages/language-services/src/utils/test-helpers.ts @@ -0,0 +1,137 @@ +import { EOL } from "node:os"; +import { join } from "path"; +import { + LanguageServiceOptions, + FileSystemProvider, + URI, + FileStat, + FileType, + TextDocument, +} from "../language-services-types"; + +class MemoryFileSystem implements FileSystemProvider { + storage: Map; + + constructor() { + this.storage = new Map(); + } + + createDocument( + lines: string[] | string, + options: { uri?: string; languageId?: string; version?: number } = {}, + ): TextDocument { + const text = Array.isArray(lines) ? lines.join(EOL) : lines; + const uri = URI.file(join(process.cwd(), options.uri || "index.scss")); + const document = TextDocument.create( + uri.toString(), + options.languageId || "scss", + options.version || 1, + text, + ); + this.storage.set(uri.toString(), document); + return document; + } + + findFiles() { + return Promise.resolve([...this.storage.keys()].map((s) => URI.parse(s))); + } + + async stat(uri: URI): Promise { + try { + const file = this.storage.get(uri.toString()); + let type = FileType.Unknown; + if (file) { + type = FileType.File; + } else { + type = FileType.Directory; + } + + const now = new Date(); + return { + type, + ctime: now.getTime(), + mtime: now.getTime(), + size: file?.getText().length || 0, + }; + } catch (e) { + return { + type: FileType.Unknown, + ctime: -1, + mtime: -1, + size: -1, + }; + } + } + + readFile(uri: URI) { + const doc = this.storage.get(uri.toString()); + return Promise.resolve(doc?.getText() || ""); + } + + private getName(uriString: string): string { + if (uriString.endsWith("/")) { + uriString = uriString.slice(0, uriString.length - 1); + } + return uriString.substring(uriString.lastIndexOf("/") + 1); + } + + async readDirectory(uri: URI): Promise<[string, FileType][]> { + const toMatch = uri.toString(); + const result: [string, FileType][] = []; + for (const file of this.storage.keys()) { + if (!file.startsWith(toMatch)) { + continue; + } + + const directoryIndex = file.indexOf(toMatch); + if (directoryIndex === -1) { + continue; + } + + let fileType = FileType.File; + let name = this.getName(file); + const subdirectoryIndex = file.indexOf("/", toMatch.length + 1); + if (subdirectoryIndex !== -1) { + const subdirectory = file.substring(0, subdirectoryIndex); + const subsub = file.indexOf("/", subdirectory.length + 1); + if (subsub !== -1) { + // Files or folders in subdirectories should not be included + continue; + } + + name = this.getName(subdirectory); + fileType = FileType.Directory; + } + + result.push([name, fileType]); + } + return result; + } + + exists(uri: URI) { + return Promise.resolve(Boolean(this.storage.get(uri.toString()))); + } + + realPath(uri: URI) { + return Promise.resolve(uri); + } +} + +export function getOptions(): LanguageServiceOptions & { + fileSystemProvider: MemoryFileSystem; +} { + const fileSystemProvider = new MemoryFileSystem(); + return { + fileSystemProvider, + clientCapabilities: { + textDocument: { + completion: { + completionItem: { documentationFormat: ["markdown", "plaintext"] }, + }, + hover: { + contentFormat: ["markdown", "plaintext"], + }, + }, + }, + }; +} diff --git a/packages/language-services/tsconfig.json b/packages/language-services/tsconfig.json new file mode 100644 index 00000000..2b0655a9 --- /dev/null +++ b/packages/language-services/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["ES2020", "WebWorker"], + "sourceMap": true, + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "strict": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/language-services/vitest.config.mts b/packages/language-services/vitest.config.mts new file mode 100644 index 00000000..e2df9da5 --- /dev/null +++ b/packages/language-services/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + }, +}); diff --git a/packages/vscode-css-languageservice/.editorconfig b/packages/vscode-css-languageservice/.editorconfig new file mode 100644 index 00000000..00725063 --- /dev/null +++ b/packages/vscode-css-languageservice/.editorconfig @@ -0,0 +1,5 @@ +indent_style = tab +indent_size = 2 + +[*.json] +indent_style = space \ No newline at end of file diff --git a/packages/vscode-css-languageservice/.eslintrc.json b/packages/vscode-css-languageservice/.eslintrc.json new file mode 100644 index 00000000..7a75dad3 --- /dev/null +++ b/packages/vscode-css-languageservice/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/naming-convention": [ + "warn", + { + "selector": "typeLike", + "format": ["PascalCase"] + } + ], + "@typescript-eslint/semi": "warn", + "curly": "warn", + "eqeqeq": "warn", + "no-throw-literal": "warn", + "semi": "off", + "no-unused-expressions": "warn", + "no-duplicate-imports": "warn", + "new-parens": "warn" + } +} diff --git a/packages/vscode-css-languageservice/.gitignore b/packages/vscode-css-languageservice/.gitignore new file mode 100644 index 00000000..2d9f632f --- /dev/null +++ b/packages/vscode-css-languageservice/.gitignore @@ -0,0 +1,5 @@ +lib/ +node_modules/ +coverage/ +.nyc_output/ +npm-debug.log \ No newline at end of file diff --git a/packages/vscode-css-languageservice/.mocharc.json b/packages/vscode-css-languageservice/.mocharc.json new file mode 100644 index 00000000..fbf679e0 --- /dev/null +++ b/packages/vscode-css-languageservice/.mocharc.json @@ -0,0 +1,6 @@ +{ + "ui": "tdd", + "color": true, + "spec": "./lib/umd/test/**/*.test.js", + "recursive": true +} \ No newline at end of file diff --git a/packages/vscode-css-languageservice/.npmignore b/packages/vscode-css-languageservice/.npmignore new file mode 100644 index 00000000..f282cfef --- /dev/null +++ b/packages/vscode-css-languageservice/.npmignore @@ -0,0 +1,19 @@ +.vscode/ +.github/ +lib/*/test/ +lib/**/*.js.map +lib/*/*/*.d.ts +src/ +build/ +coverage/ +test/ +.eslintrc.json +.gitignore +.travis.yml +gulpfile.js +tslint.json +package-lock.json +azure-pipelines.yml +.editorconfig +.mocharc.json +.nyc_output/ \ No newline at end of file diff --git a/packages/vscode-css-languageservice/.prettierrc b/packages/vscode-css-languageservice/.prettierrc new file mode 100644 index 00000000..d26b97a6 --- /dev/null +++ b/packages/vscode-css-languageservice/.prettierrc @@ -0,0 +1,5 @@ +{ + "useTabs": true, + "printWidth": 120, + "semi": true +} diff --git a/packages/vscode-css-languageservice/LICENSE.md b/packages/vscode-css-languageservice/LICENSE.md new file mode 100644 index 00000000..f54f08dc --- /dev/null +++ b/packages/vscode-css-languageservice/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Microsoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vscode-css-languageservice/README.md b/packages/vscode-css-languageservice/README.md new file mode 100644 index 00000000..e13e3ddd --- /dev/null +++ b/packages/vscode-css-languageservice/README.md @@ -0,0 +1,9 @@ +# @somesass/vscode-css-languageservice + +Experimental fork, goal is to upstream changes and remove the fork if possible. + +Candidates: + +- [ ] findDocumentLinks extension with use and forward metadata +- [ ] placeholder selectors and usages in symbols +- [ ] details with parameters for functions and mixins, for signature helper diff --git a/packages/vscode-css-languageservice/build/generateData.js b/packages/vscode-css-languageservice/build/generateData.js new file mode 100644 index 00000000..f0187acd --- /dev/null +++ b/packages/vscode-css-languageservice/build/generateData.js @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const fs = require('fs') +const path = require('path') +const os = require('os') + +const customData = require('@vscode/web-custom-data/data/browsers.css-data.json'); + +function toJavaScript(obj) { + return JSON.stringify(obj, null, '\t'); +} + +const DATA_TYPE = 'CSSDataV1'; +const output = [ + '/*---------------------------------------------------------------------------------------------', + ' * Copyright (c) Microsoft Corporation. All rights reserved.', + ' * Licensed under the MIT License. See License.txt in the project root for license information.', + ' *--------------------------------------------------------------------------------------------*/', + '// file generated from @vscode/web-custom-data NPM package', + '', + `import { ${DATA_TYPE} } from '../cssLanguageTypes';`, + '', + `export const cssData : ${DATA_TYPE} = ` + toJavaScript(customData) + ';' +]; + +var outputPath = path.resolve(__dirname, '../src/data/webCustomData.ts'); +console.log('Writing to: ' + outputPath); +var content = output.join(os.EOL); +fs.writeFileSync(outputPath, content); +console.log('Done'); diff --git a/packages/vscode-css-languageservice/build/remove-sourcemap-refs.js b/packages/vscode-css-languageservice/build/remove-sourcemap-refs.js new file mode 100644 index 00000000..89a09974 --- /dev/null +++ b/packages/vscode-css-languageservice/build/remove-sourcemap-refs.js @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const fs = require('fs'); +const path = require('path'); + +function deleteRefs(dir) { + const files = fs.readdirSync(dir); + for (let file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + deleteRefs(filePath); + } else if (path.extname(file) === '.js') { + const content = fs.readFileSync(filePath, 'utf8'); + const newContent = content.replace(/\/\/\# sourceMappingURL=[^]+.js.map/, '') + if (content.length !== newContent.length) { + console.log('remove sourceMappingURL in ' + filePath); + fs.writeFileSync(filePath, newContent); + } + } else if (path.extname(file) === '.map') { + fs.unlinkSync(filePath) + console.log('remove ' + filePath); + } + } +} + +let location = path.join(__dirname, '..', 'lib'); +console.log('process ' + location); +deleteRefs(location); \ No newline at end of file diff --git a/packages/vscode-css-languageservice/docs/customData.md b/packages/vscode-css-languageservice/docs/customData.md new file mode 100644 index 00000000..f4ccb31c --- /dev/null +++ b/packages/vscode-css-languageservice/docs/customData.md @@ -0,0 +1,110 @@ +# Custom Data for CSS Language Service + +In VS Code, there are two ways of loading custom CSS datasets: + +1. With setting `css.customData` +```json + "css.customData": [ + "./foo.css-data.json" + ] +``` +2. With an extension that contributes `contributes.css.customData` + +Both setting point to a list of JSON files. This document describes the shape of the JSON files. + +You can read more about custom data at: https://github.com/microsoft/vscode-custom-data. + +## Custom Data Format + +### Overview + +The JSON have one required property, `version`, and 4 other top level properties: + +```jsonc +{ + "version": 1.1, + "properties": [], + "atDirectives": [], + "pseudoClasses": [], + "pseudoElements": [] +} +``` + +Version denotes the schema version you are using. The latest schema version is `V1.1`. + +You can find other properties' shapes at [cssLanguageTypes.ts](../src/cssLanguageTypes.ts) or the [JSON Schema](./customData.schema.json). + +You should suffix your custom data file with `.css-data.json`, so VS Code will load the most recent schema for the JSON file to offer auto completion and error checking. + +### Format + +All top-level properties share two basic properties, `name` and `description`. For example: + +```jsonc +{ + "version": 1.1, + "properties": [ + { "name": "foo", "description": "Foo property" } + ], + "atDirectives": [ + { "name": "@foo", "description": "Foo at directive" } + ], + "pseudoClasses": [ + { "name": ":foo", "description": "Foo pseudo class" } + ], + "pseudoElements": [ + { "name": "::foo", "description": "Foo pseudo elements" } + ] +} +``` + +You can also specify 4 additional properties for them: + +```jsonc +{ + "properties": [ + { + "name": "foo", + "description": "Foo property", + "browsers": [ + "E12", + "S10", + "C50", + "IE10", + "O37" + ], + "status": "standard", + "references": [ + { + "name": "My foo property reference", + "url": "https://www.foo.com/property/foo" + } + ], + "relevance": 25 + } + ] +} +``` + +- `browsers`: A list of supported browsers. The format is `browserName + version`. For example: `['E10', 'C30', 'FF20']`. Here are all browser names: + ``` + export let browserNames = { + E: 'Edge', + FF: 'Firefox', + S: 'Safari', + C: 'Chrome', + IE: 'IE', + O: 'Opera' + }; + ``` + The browser compatibility will be rendered at completion and hover. Items that is supported in only one browser are dropped from completion. + +- `status`: The status of the item. The format is: + ``` + export type EntryStatus = 'standard' | 'experimental' | 'nonstandard' | 'obsolete'; + ``` + The status will be rendered at the top of completion and hover. For example, `nonstandard` items are prefixed with the message `🚨️ Property is nonstandard. Avoid using it.`. + +- `references`: A list of references. They will be displayed in Markdown form in completion and hover as `[Ref1 Name](Ref1 URL) | [Ref2 Name](Ref2 URL) | ...`. + +- `relevance`: A number in the range [0, 100] used for sorting. Bigger number means more relevant and will be sorted first. Entries that do not specify a relevance will get 50 as default value. diff --git a/packages/vscode-css-languageservice/docs/customData.schema.json b/packages/vscode-css-languageservice/docs/customData.schema.json new file mode 100644 index 00000000..57373448 --- /dev/null +++ b/packages/vscode-css-languageservice/docs/customData.schema.json @@ -0,0 +1,245 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "vscode-css-customdata", + "version": 1.1, + "title": "VS Code CSS Custom Data format", + "description": "Format for loading Custom Data in VS Code's CSS support", + "type": "object", + "required": ["version"], + "definitions": { + "references": { + "type": "object", + "required": ["name", "url"], + "properties": { + "name": { + "type": "string", + "description": "The name of the reference." + }, + "url": { + "type": "string", + "description": "The URL of the reference.", + "pattern": "https?:\/\/", + "patternErrorMessage": "URL should start with http:// or https://" + } + } + }, + "markupDescription": { + "type": "object", + "required": ["kind", "value"], + "properties": { + "kind": { + "type": "string", + "description": "Whether `description.value` should be rendered as plaintext or markdown", + "enum": ["plaintext", "markdown"] + }, + "value": { + "type": "string", + "description": "Description shown in completion and hover" + } + } + } + }, + "properties": { + "version": { + "const": 1.1, + "description": "The custom data version", + "type": "number" + }, + "properties": { + "description": "Custom CSS properties", + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "defaultSnippets": [ + { + "body": { + "name": "$1", + "description": "" + } + } + ], + "properties": { + "name": { + "type": "string", + "description": "Name of property" + }, + "description": { + "description": "Description of property shown in completion and hover", + "anyOf": [ + { + "type": "string" + }, + { "$ref": "#/definitions/markupDescription" } + ] + }, + "status": { + "type": "string", + "description": "Browser status", + "enum": ["standard", "experimental", "nonstandard", "obsolete"] + }, + "browsers": { + "type": "array", + "description": "Supported browsers", + "items": { + "type": "string", + "pattern": "(E|FF|S|C|IE|O)([\\d|\\.]+)?", + "patternErrorMessage": "Browser item must follow the format of `${browser}${version}`. `browser` is one of:\n- E: Edge\n- FF: Firefox\n- S: Safari\n- C: Chrome\n- IE: Internet Explorer\n- O: Opera" + } + }, + "references": { + "type": "array", + "description": "A list of references for the property shown in completion and hover", + "items": { + "$ref": "#/definitions/references" + } + }, + "relevance": { + "type": "number", + "description": "A number in the range [0, 100] used for sorting. Bigger number means more relevant and will be sorted first. Entries that do not specify a relevance will get 50 as default value.", + "minimum": 0, + "exclusiveMaximum": 100 + } + } + } + }, + "atDirectives": { + "description": "Custom CSS at directives", + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "defaultSnippets": [ + { + "body": { + "name": "@$1", + "description": "" + } + } + ], + "properties": { + "name": { + "type": "string", + "description": "Name of at directive", + "pattern": "^@.+", + "patternErrorMessage": "Pseudo class must start with `@`" + }, + "description": { + "description": "Description of at directive shown in completion and hover", + "anyOf": [ + { + "type": "string" + }, + { "$ref": "#/definitions/markupDescription" } + ] + }, + "status": { + "$ref": "#/properties/properties/items/properties/status" + }, + "browsers": { + "$ref": "#/properties/properties/items/properties/browsers" + }, + "references": { + "type": "array", + "description": "A list of references for the at-directive shown in completion and hover", + "items": { + "$ref": "#/definitions/references" + } + } + } + } + }, + "pseudoClasses": { + "description": "Custom CSS pseudo classes", + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "defaultSnippets": [ + { + "body": { + "name": ":$1", + "description": "" + } + } + ], + "properties": { + "name": { + "type": "string", + "description": "Name of pseudo class", + "pattern": "^:.+", + "patternErrorMessage": "Pseudo class must start with `:`" + }, + "description": { + "description": "Description of pseudo class shown in completion and hover", + "anyOf": [ + { + "type": "string" + }, + { "$ref": "#/definitions/markupDescription" } + ] + }, + "status": { + "$ref": "#/properties/properties/items/properties/status" + }, + "browsers": { + "$ref": "#/properties/properties/items/properties/browsers" + }, + "references": { + "type": "array", + "description": "A list of references for the pseudo-class shown in completion and hover", + "items": { + "$ref": "#/definitions/references" + } + } + } + } + }, + "pseudoElements": { + "description": "Custom CSS pseudo elements", + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "defaultSnippets": [ + { + "body": { + "name": "::$1", + "description": "" + } + } + ], + "properties": { + "name": { + "type": "string", + "description": "Name of pseudo element", + "pattern": "^::.+", + "patternErrorMessage": "Pseudo class must start with `::`" + }, + "description": { + "description": "Description of pseudo element shown in completion and hover", + "anyOf": [ + { + "type": "string" + }, + { "$ref": "#/definitions/markupDescription" } + ] + }, + "status": { + "$ref": "#/properties/properties/items/properties/status" + }, + "browsers": { + "$ref": "#/properties/properties/items/properties/browsers" + }, + "references": { + "type": "array", + "description": "A list of references for the pseudo-element shown in completion and hover", + "items": { + "$ref": "#/definitions/references" + } + } + } + } + } + } +} diff --git a/packages/vscode-css-languageservice/package-lock.json b/packages/vscode-css-languageservice/package-lock.json new file mode 100644 index 00000000..400203ff --- /dev/null +++ b/packages/vscode-css-languageservice/package-lock.json @@ -0,0 +1,2760 @@ +{ + "name": "vscode-css-languageservice", + "version": "6.2.13", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-css-languageservice", + "version": "6.2.13", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.0.8" + }, + "devDependencies": { + "@types/mocha": "^10.0.6", + "@types/node": "16.x", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vscode/web-custom-data": "^0.4.9", + "eslint": "^8.57.0", + "js-beautify": "^1.15.1", + "mocha": "^10.3.0", + "rimraf": "^5.0.5", + "source-map-support": "^0.5.21", + "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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "license": "MIT", + "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.8.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.0.tgz", + "integrity": "sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg==", + "dev": true, + "license": "MIT", + "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.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.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, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true, + "license": "MIT" + }, + "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.6", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", + "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "16.11.36", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.36.tgz", + "integrity": "sha512-FR5QJe+TaoZ2GsMHkjuwoNabr+UrJNRr2HNOo+r/7vhcuntM6Ee/pRPOnRhhL2XE9OOvX9VLEq+BcXl3VjNoWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.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.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.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.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.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.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.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.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "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.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.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/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "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.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.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.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.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, + "license": "ISC" + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==" + }, + "node_modules/@vscode/web-custom-data": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@vscode/web-custom-data/-/web-custom-data-0.4.9.tgz", + "integrity": "sha512-QeCJFISE/RiTG0NECX6DYmVRPVb0jdyaUrhY0JqNMv9ruUYtYqxxQfv3PSjogb+zNghmwgXLSYuQKk6G+Xnaig==", + "dev": true + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "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, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "license": "ISC", + "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, + "license": "Python-2.0" + }, + "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/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, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", + "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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, + "license": "MIT", + "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, + "license": "ISC" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "license": "MIT", + "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/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/" + } + ], + "license": "MIT", + "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, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.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, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "license": "MIT" + }, + "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, + "license": "BSD-3-Clause", + "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, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@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-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, + "license": "BSD-2-Clause", + "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, + "license": "ISC", + "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, + "license": "MIT", + "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, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz", + "integrity": "sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "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, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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-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": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.8.0.tgz", + "integrity": "sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q==", + "dev": true, + "license": "ISC", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "BSD-3-Clause", + "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, + "license": "MIT", + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache/node_modules/glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "license": "ISC", + "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": "*" + } + }, + "node_modules/flat-cache/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, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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, + "license": "ISC" + }, + "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, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "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, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "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, + "license": "MIT", + "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, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "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, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", + "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", + "dev": true, + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.3.3", + "js-cookie": "^3.0.5", + "nopt": "^7.2.0" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "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/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, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimatch/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, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/minipass": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", + "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.3.0.tgz", + "integrity": "sha512-uF2XJs+7xSLsrmIvn37i/wnc91nw7XjOQB8ccyx5aEgdnohr7n+rEiZP23WkCYHjilR6+EboEnbq/ZQDz4LSbg==", + "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": "8.1.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.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" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "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/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, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/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, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "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, + "license": "MIT" + }, + "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, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz", + "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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, + "license": "MIT", + "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.0.2", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.0.2.tgz", + "integrity": "sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.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, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", + "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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, + "license": "MIT", + "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": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", + "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "14 || >=16.14" + } + }, + "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/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, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "dev": true, + "license": "ISC" + }, + "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/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", + "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", + "integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==", + "dev": true, + "license": "MIT" + }, + "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" + } + ], + "license": "MIT" + }, + "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, + "license": "ISC", + "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, + "license": "BSD-3-Clause", + "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, + "license": "MIT", + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.1.tgz", + "integrity": "sha512-uUWsN4aOxJAS8KOuf3QMyFtgm1pkb6I+KRZbRF/ghdf5T7sM+B1lLLzPDxswUjkmHyxQAVzEgG35E3NzDM9GVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "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, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "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, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.2.tgz", + "integrity": "sha512-Cbu4nIqnEdd+THNEsBdkolnOXhg0I8XteoHaEKgvsxpsbWda4IsUut2c187HxywQCvveojow0Dgw/amxtSKVkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "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, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/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-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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, + "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, + "license": "ISC", + "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, + "license": "Apache-2.0" + }, + "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, + "license": "MIT", + "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-cjs": { + "name": "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, + "license": "MIT", + "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-cjs/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, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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, + "license": "MIT", + "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/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, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/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, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.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, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "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, + "license": "ISC" + }, + "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, + "license": "MIT", + "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, + "license": "ISC", + "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, + "license": "MIT", + "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/yargs-parser": { + "version": "20.2.7", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz", + "integrity": "sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + } + } +} diff --git a/packages/vscode-css-languageservice/package.json b/packages/vscode-css-languageservice/package.json new file mode 100644 index 00000000..6948e8e5 --- /dev/null +++ b/packages/vscode-css-languageservice/package.json @@ -0,0 +1,50 @@ +{ + "name": "@somesass/vscode-css-languageservice", + "version": "1.0.0", + "private": true, + "description": "Language service for CSS, LESS and SCSS", + "main": "./lib/umd/cssLanguageService.js", + "typings": "./lib/umd/cssLanguageService", + "module": "./lib/esm/cssLanguageService.js", + "author": "Microsoft Corporation", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/vscode-css-languageservice" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/Microsoft/vscode-css-languageservice" + }, + "devDependencies": { + "@types/mocha": "^10.0.6", + "@types/node": "20.12.7", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vscode/web-custom-data": "^0.4.9", + "eslint": "^8.57.0", + "mocha": "^10.3.0", + "nyc": "15.1.0", + "rimraf": "^5.0.5", + "source-map-support": "^0.5.21", + "typescript": "^5.3.3" + }, + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.0.8" + }, + "scripts": { + "build": "npm run compile && npm run compile-esm", + "compile": "tsc -p ./src && npm run lint", + "compile-esm": "tsc -p ./src/tsconfig.esm.json", + "clean": "rimraf lib", + "watch": "tsc -w -p ./src", + "test": "npm run compile && npm run mocha", + "mocha": "mocha --require source-map-support/register", + "coverage": "npm run compile && nyc --reporter=html --reporter=text mocha", + "lint": "eslint src/**/*.ts", + "update-data": "npm install @vscode/web-custom-data -D && node ./build/generateData.js", + "install-types-next": "npm install vscode-languageserver-types@next -f -S && npm install vscode-languageserver-textdocument@next -f -S" + } +} diff --git a/packages/vscode-css-languageservice/src/cssLanguageService.ts b/packages/vscode-css-languageservice/src/cssLanguageService.ts new file mode 100644 index 00000000..123196a4 --- /dev/null +++ b/packages/vscode-css-languageservice/src/cssLanguageService.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +"use strict"; + +import { Parser } from "./parser/cssParser"; +import { CSSCompletion } from "./services/cssCompletion"; +import { CSSHover } from "./services/cssHover"; +import { CSSNavigation } from "./services/cssNavigation"; +import { CSSCodeActions } from "./services/cssCodeActions"; +import { CSSValidation } from "./services/cssValidation"; + +import { SCSSParser } from "./parser/scssParser"; +import { SCSSCompletion } from "./services/scssCompletion"; +import { getFoldingRanges } from "./services/cssFolding"; + +import { + LanguageSettings, + ICompletionParticipant, + DocumentContext, + LanguageServiceOptions, + Diagnostic, + Position, + CompletionList, + Hover, + Location, + DocumentHighlight, + SymbolInformation, + Range, + CodeActionContext, + Command, + CodeAction, + ColorInformation, + Color, + ColorPresentation, + WorkspaceEdit, + FoldingRange, + SelectionRange, + TextDocument, + ICSSDataProvider, + CSSDataV1, + HoverSettings, + CompletionSettings, + DocumentSymbol, + StylesheetDocumentLink, +} from "./cssLanguageTypes"; + +import { CSSDataManager } from "./languageFacts/dataManager"; +import { CSSDataProvider } from "./languageFacts/dataProvider"; +import { getSelectionRanges } from "./services/cssSelectionRange"; +import { SCSSNavigation } from "./services/scssNavigation"; +import { cssData } from "./data/webCustomData"; + +export type Stylesheet = {}; + +export { TokenType, IToken, Scanner } from "./parser/cssScanner"; +export { SCSSScanner } from "./parser/scssScanner"; +export * from "./parser/cssNodes"; +export * from "./cssLanguageTypes"; + +export interface LanguageService { + configure(raw?: LanguageSettings): void; + setDataProviders(useDefaultDataProvider: boolean, customDataProviders: ICSSDataProvider[]): void; + doValidation(document: TextDocument, stylesheet: Stylesheet, documentSettings?: LanguageSettings): Diagnostic[]; + parseStylesheet(document: TextDocument): Stylesheet; + doComplete( + document: TextDocument, + position: Position, + stylesheet: Stylesheet, + settings?: CompletionSettings, + ): CompletionList; + doComplete2( + document: TextDocument, + position: Position, + stylesheet: Stylesheet, + documentContext: DocumentContext, + settings?: CompletionSettings, + ): Promise; + setCompletionParticipants(registeredCompletionParticipants: ICompletionParticipant[]): void; + doHover(document: TextDocument, position: Position, stylesheet: Stylesheet, settings?: HoverSettings): Hover | null; + findDefinition(document: TextDocument, position: Position, stylesheet: Stylesheet): Location | null; + findReferences(document: TextDocument, position: Position, stylesheet: Stylesheet): Location[]; + findDocumentHighlights(document: TextDocument, position: Position, stylesheet: Stylesheet): DocumentHighlight[]; + findDocumentLinks( + document: TextDocument, + stylesheet: Stylesheet, + documentContext: DocumentContext, + ): StylesheetDocumentLink[]; + /** + * Return statically resolved links, and dynamically resolved links if `fsProvider` is proved. + */ + findDocumentLinks2( + document: TextDocument, + stylesheet: Stylesheet, + documentContext: DocumentContext, + ): Promise; + findDocumentSymbols(document: TextDocument, stylesheet: Stylesheet): SymbolInformation[]; + findDocumentSymbols2(document: TextDocument, stylesheet: Stylesheet): DocumentSymbol[]; + doCodeActions(document: TextDocument, range: Range, context: CodeActionContext, stylesheet: Stylesheet): Command[]; + doCodeActions2( + document: TextDocument, + range: Range, + context: CodeActionContext, + stylesheet: Stylesheet, + ): CodeAction[]; + findDocumentColors(document: TextDocument, stylesheet: Stylesheet): ColorInformation[]; + getColorPresentations( + document: TextDocument, + stylesheet: Stylesheet, + color: Color, + range: Range, + ): ColorPresentation[]; + prepareRename(document: TextDocument, position: Position, stylesheet: Stylesheet): Range | undefined; + doRename(document: TextDocument, position: Position, newName: string, stylesheet: Stylesheet): WorkspaceEdit; + getFoldingRanges(document: TextDocument, context?: { rangeLimit?: number }): FoldingRange[]; + getSelectionRanges(document: TextDocument, positions: Position[], stylesheet: Stylesheet): SelectionRange[]; +} + +export function getDefaultCSSDataProvider(): ICSSDataProvider { + return newCSSDataProvider(cssData); +} + +export function newCSSDataProvider(data: CSSDataV1): ICSSDataProvider { + return new CSSDataProvider(data); +} + +function createFacade( + parser: Parser, + completion: CSSCompletion, + hover: CSSHover, + navigation: CSSNavigation, + codeActions: CSSCodeActions, + validation: CSSValidation, + cssDataManager: CSSDataManager, +): LanguageService { + return { + configure: (settings) => { + validation.configure(settings); + completion.configure(settings?.completion); + hover.configure(settings?.hover); + navigation.configure(settings?.importAliases); + }, + setDataProviders: cssDataManager.setDataProviders.bind(cssDataManager), + doValidation: validation.doValidation.bind(validation), + parseStylesheet: parser.parseStylesheet.bind(parser), + doComplete: completion.doComplete.bind(completion), + doComplete2: completion.doComplete2.bind(completion), + setCompletionParticipants: completion.setCompletionParticipants.bind(completion), + doHover: hover.doHover.bind(hover), + findDefinition: navigation.findDefinition.bind(navigation), + findReferences: navigation.findReferences.bind(navigation), + findDocumentHighlights: navigation.findDocumentHighlights.bind(navigation), + findDocumentLinks: navigation.findDocumentLinks.bind(navigation), + findDocumentLinks2: navigation.findDocumentLinks2.bind(navigation), + findDocumentSymbols: navigation.findSymbolInformations.bind(navigation), + findDocumentSymbols2: navigation.findDocumentSymbols.bind(navigation), + doCodeActions: codeActions.doCodeActions.bind(codeActions), + doCodeActions2: codeActions.doCodeActions2.bind(codeActions), + findDocumentColors: navigation.findDocumentColors.bind(navigation), + getColorPresentations: navigation.getColorPresentations.bind(navigation), + prepareRename: navigation.prepareRename.bind(navigation), + doRename: navigation.doRename.bind(navigation), + getFoldingRanges, + getSelectionRanges, + }; +} + +const defaultLanguageServiceOptions = {}; + +export function getCSSLanguageService( + options: LanguageServiceOptions = defaultLanguageServiceOptions, +): LanguageService { + const cssDataManager = new CSSDataManager(options); + return createFacade( + new Parser(), + new CSSCompletion(null, options, cssDataManager), + new CSSHover(options && options.clientCapabilities, cssDataManager), + new CSSNavigation(options && options.fileSystemProvider, false), + new CSSCodeActions(cssDataManager), + new CSSValidation(cssDataManager), + cssDataManager, + ); +} + +export function getSCSSLanguageService( + options: LanguageServiceOptions = defaultLanguageServiceOptions, +): LanguageService { + const cssDataManager = new CSSDataManager(options); + return createFacade( + new SCSSParser(), + new SCSSCompletion(options, cssDataManager), + new CSSHover(options && options.clientCapabilities, cssDataManager), + new SCSSNavigation(options && options.fileSystemProvider), + new CSSCodeActions(cssDataManager), + new CSSValidation(cssDataManager), + cssDataManager, + ); +} diff --git a/packages/vscode-css-languageservice/src/cssLanguageTypes.ts b/packages/vscode-css-languageservice/src/cssLanguageTypes.ts new file mode 100644 index 00000000..217466f6 --- /dev/null +++ b/packages/vscode-css-languageservice/src/cssLanguageTypes.ts @@ -0,0 +1,405 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +"use strict"; + +import { + Range, + Position, + DocumentUri, + MarkupContent, + MarkupKind, + Color, + ColorInformation, + ColorPresentation, + FoldingRange, + FoldingRangeKind, + SelectionRange, + Diagnostic, + DiagnosticSeverity, + CompletionItem, + CompletionItemKind, + CompletionList, + CompletionItemTag, + InsertTextFormat, + DefinitionLink, + SymbolInformation, + SymbolKind, + DocumentSymbol, + Location, + Hover, + MarkedString, + CodeActionContext, + Command, + CodeAction, + DocumentHighlight, + DocumentLink, + WorkspaceEdit, + TextEdit, + CodeActionKind, + TextDocumentEdit, + VersionedTextDocumentIdentifier, + DocumentHighlightKind, +} from "vscode-languageserver-types"; + +import { TextDocument } from "vscode-languageserver-textdocument"; +import { NodeType } from "./cssLanguageService"; + +export { + TextDocument, + Range, + Position, + DocumentUri, + MarkupContent, + MarkupKind, + Color, + ColorInformation, + ColorPresentation, + FoldingRange, + FoldingRangeKind, + SelectionRange, + Diagnostic, + DiagnosticSeverity, + CompletionItem, + CompletionItemKind, + CompletionList, + CompletionItemTag, + InsertTextFormat, + DefinitionLink, + SymbolInformation, + SymbolKind, + DocumentSymbol, + Location, + Hover, + MarkedString, + CodeActionContext, + Command, + CodeAction, + DocumentHighlight, + DocumentLink, + WorkspaceEdit, + TextEdit, + CodeActionKind, + TextDocumentEdit, + VersionedTextDocumentIdentifier, + DocumentHighlightKind, +}; + +export type LintSettings = { [key: string]: any }; + +export interface CompletionSettings { + triggerPropertyValueCompletion: boolean; + completePropertyWithSemicolon?: boolean; +} + +export interface LanguageSettings { + validate?: boolean; + lint?: LintSettings; + completion?: CompletionSettings; + hover?: HoverSettings; + importAliases?: AliasSettings; +} + +export interface AliasSettings { + [key: string]: string; +} + +export interface HoverSettings { + documentation?: boolean; + references?: boolean; +} + +export interface PropertyCompletionContext { + propertyName: string; + range: Range; +} + +export interface PropertyValueCompletionContext { + propertyName: string; + propertyValue?: string; + range: Range; +} + +export interface URILiteralCompletionContext { + uriValue: string; + position: Position; + range: Range; +} + +export interface ImportPathCompletionContext { + pathValue: string; + position: Position; + range: Range; +} + +export interface MixinReferenceCompletionContext { + mixinName: string; + range: Range; +} + +export interface ICompletionParticipant { + onCssProperty?: (context: PropertyCompletionContext) => void; + onCssPropertyValue?: (context: PropertyValueCompletionContext) => void; + onCssURILiteralValue?: (context: URILiteralCompletionContext) => void; + onCssImportPath?: (context: ImportPathCompletionContext) => void; + onCssMixinReference?: (context: MixinReferenceCompletionContext) => void; +} + +export interface DocumentContext { + resolveReference(ref: string, baseUrl: string): string | undefined; +} + +/** + * Describes what LSP capabilities the client supports + */ +export interface ClientCapabilities { + /** + * The text document client capabilities + */ + textDocument?: { + /** + * Capabilities specific to completions. + */ + completion?: { + /** + * The client supports the following `CompletionItem` specific + * capabilities. + */ + completionItem?: { + /** + * Client supports the follow content formats for the documentation + * property. The order describes the preferred format of the client. + */ + documentationFormat?: MarkupKind[]; + }; + }; + /** + * Capabilities specific to hovers. + */ + hover?: { + /** + * Client supports the follow content formats for the content + * property. The order describes the preferred format of the client. + */ + contentFormat?: MarkupKind[]; + }; + }; +} + +export namespace ClientCapabilities { + export const LATEST: ClientCapabilities = { + textDocument: { + completion: { + completionItem: { + documentationFormat: [MarkupKind.Markdown, MarkupKind.PlainText], + }, + }, + hover: { + contentFormat: [MarkupKind.Markdown, MarkupKind.PlainText], + }, + }, + }; +} + +export interface LanguageServiceOptions { + /** + * Unless set to false, the default CSS data provider will be used + * along with the providers from customDataProviders. + * Defaults to true. + */ + useDefaultDataProvider?: boolean; + + /** + * Provide data that could enhance the service's understanding of + * CSS property / at-rule / pseudo-class / pseudo-element + */ + customDataProviders?: ICSSDataProvider[]; + + /** + * Abstract file system access away from the service. + * Used for dynamic link resolving, path completion, etc. + */ + fileSystemProvider?: FileSystemProvider; + + /** + * Describes the LSP capabilities the client supports. + */ + clientCapabilities?: ClientCapabilities; +} + +export type EntryStatus = "standard" | "experimental" | "nonstandard" | "obsolete"; + +export interface IReference { + name: string; + url: string; +} + +export interface IPropertyData { + name: string; + description?: string | MarkupContent; + browsers?: string[]; + restrictions?: string[]; + status?: EntryStatus; + syntax?: string; + values?: IValueData[]; + references?: IReference[]; + relevance?: number; + atRule?: string; +} +export interface IAtDirectiveData { + name: string; + description?: string | MarkupContent; + browsers?: string[]; + status?: EntryStatus; + references?: IReference[]; +} +export interface IPseudoClassData { + name: string; + description?: string | MarkupContent; + browsers?: string[]; + status?: EntryStatus; + references?: IReference[]; +} +export interface IPseudoElementData { + name: string; + description?: string | MarkupContent; + browsers?: string[]; + status?: EntryStatus; + references?: IReference[]; +} + +export interface IValueData { + name: string; + description?: string | MarkupContent; + browsers?: string[]; + status?: EntryStatus; + references?: IReference[]; +} + +export interface CSSDataV1 { + version: 1 | 1.1; + properties?: IPropertyData[]; + atDirectives?: IAtDirectiveData[]; + pseudoClasses?: IPseudoClassData[]; + pseudoElements?: IPseudoElementData[]; +} + +export interface ICSSDataProvider { + provideProperties(): IPropertyData[]; + provideAtDirectives(): IAtDirectiveData[]; + providePseudoClasses(): IPseudoClassData[]; + providePseudoElements(): IPseudoElementData[]; +} + +export interface StylesheetDocumentLink extends DocumentLink { + /** + * The namespace of the module. Either equal to {@link as} or derived from {@link target}. + * + * | Link | Value | + * | ------------------ | ---------- | + * | `"./colors"` | `"colors"` | + * | `"./colors" as c` | `"c"` | + * | `"./colors" as *` | `"*"` | + * | `"./_colors"` | `"colors"` | + * | `"./_colors.scss"` | `"colors"` | + * + * @see https://sass-lang.com/documentation/at-rules/use/#choosing-a-namespace + */ + namespace?: string; + /** + * | Link | Value | + * | ---------------------------- | ----------- | + * | `@use "./colors"` | `undefined` | + * | `@use "./colors" as c` | `"c"` | + * | `@use "./colors" as *` | `"*"` | + * | `@forward "./colors"` | `undefined` | + * | `@forward "./colors" as c-*` | `"c"` | + * + * @see https://sass-lang.com/documentation/at-rules/use/#choosing-a-namespace + * @see https://sass-lang.com/documentation/at-rules/forward/#adding-a-prefix + */ + as?: string; + /** + * @see https://sass-lang.com/documentation/at-rules/forward/#controlling-visibility + */ + hide?: string[]; + /** + * @see https://sass-lang.com/documentation/at-rules/forward/#controlling-visibility + */ + show?: string[]; + type?: NodeType; +} + +export enum FileType { + /** + * The file type is unknown. + */ + Unknown = 0, + /** + * A regular file. + */ + File = 1, + /** + * A directory. + */ + Directory = 2, + /** + * A symbolic link to a file. + */ + SymbolicLink = 64, +} + +export interface FileStat { + /** + * The type of the file, e.g. is a regular file, a directory, or symbolic link + * to a file. + */ + type: FileType; + /** + * The creation timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + ctime: number; + /** + * The modification timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + mtime: number; + /** + * The size in bytes. + */ + size: number; +} + +export interface FileSystemProvider { + stat(uri: DocumentUri): Promise; + readDirectory?(uri: DocumentUri): Promise<[string, FileType][]>; + getContent?(uri: DocumentUri, encoding?: BufferEncoding): Promise; +} + +export interface CSSFormatConfiguration { + /** indentation size. Default: 4 */ + tabSize?: number; + /** Whether to use spaces or tabs */ + insertSpaces?: boolean; + /** end with a newline: Default: false */ + insertFinalNewline?: boolean; + /** separate selectors with newline (e.g. "a,\nbr" or "a, br"): Default: true */ + newlineBetweenSelectors?: boolean; + /** add a new line after every css rule: Default: true */ + newlineBetweenRules?: boolean; + /** ensure space around selector separators: '>', '+', '~' (e.g. "a>b" -> "a > b"): Default: false */ + spaceAroundSelectorSeparator?: boolean; + /** put braces on the same line as rules (`collapse`), or put braces on own line, Allman / ANSI style (`expand`). Default `collapse` */ + braceStyle?: "collapse" | "expand"; + /** whether existing line breaks before elements should be preserved. Default: true */ + preserveNewLines?: boolean; + /** maximum number of line breaks to be preserved in one chunk. Default: unlimited */ + maxPreserveNewLines?: number; + /** maximum amount of characters per line (0/undefined = disabled). Default: disabled. */ + wrapLineLength?: number; + /** add indenting whitespace to empty lines. Default: false */ + indentEmptyLines?: boolean; + + /** @deprecated Use newlineBetweenSelectors instead*/ + selectorSeparatorNewline?: boolean; +} diff --git a/packages/vscode-css-languageservice/src/data/webCustomData.ts b/packages/vscode-css-languageservice/src/data/webCustomData.ts new file mode 100644 index 00000000..9a2d97cc --- /dev/null +++ b/packages/vscode-css-languageservice/src/data/webCustomData.ts @@ -0,0 +1,20354 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// file generated from @vscode/web-custom-data NPM package + +import { CSSDataV1 } from "../cssLanguageTypes"; + +export const cssData: CSSDataV1 = { + version: 1.1, + properties: [ + { + name: "additive-symbols", + browsers: ["FF33"], + atRule: "@counter-style", + syntax: "[ && ]#", + relevance: 50, + description: + "@counter-style descriptor. Specifies the symbols used by the marker-construction algorithm specified by the system descriptor. Needs to be specified if the counter system is 'additive'.", + restrictions: ["integer", "string", "image", "identifier"], + }, + { + name: "align-content", + browsers: ["E12", "FF28", "S9", "C29", "IE11", "O16"], + values: [ + { + name: "center", + description: "Lines are packed toward the center of the flex container.", + }, + { + name: "flex-end", + description: "Lines are packed toward the end of the flex container.", + }, + { + name: "flex-start", + description: "Lines are packed toward the start of the flex container.", + }, + { + name: "space-around", + description: "Lines are evenly distributed in the flex container, with half-size spaces on either end.", + }, + { + name: "space-between", + description: "Lines are evenly distributed in the flex container.", + }, + { + name: "stretch", + description: "Lines stretch to take up the remaining space.", + }, + { + name: "start", + }, + { + name: "end", + }, + { + name: "normal", + }, + { + name: "baseline", + }, + { + name: "first baseline", + }, + { + name: "last baseline", + }, + { + name: "space-around", + }, + { + name: "space-between", + }, + { + name: "space-evenly", + }, + { + name: "stretch", + }, + { + name: "safe", + }, + { + name: "unsafe", + }, + ], + syntax: "normal | | | ? ", + relevance: 66, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/align-content", + }, + ], + description: + "Aligns a flex container's lines within the flex container when there is extra space in the cross-axis, similar to how 'justify-content' aligns individual items within the main-axis.", + restrictions: ["enum"], + }, + { + name: "align-items", + browsers: ["E12", "FF20", "S9", "C29", "IE11", "O16"], + values: [ + { + name: "baseline", + description: + "If the flex item's inline axis is the same as the cross axis, this value is identical to 'flex-start'. Otherwise, it participates in baseline alignment.", + }, + { + name: "center", + description: "The flex item's margin box is centered in the cross axis within the line.", + }, + { + name: "flex-end", + description: + "The cross-end margin edge of the flex item is placed flush with the cross-end edge of the line.", + }, + { + name: "flex-start", + description: + "The cross-start margin edge of the flex item is placed flush with the cross-start edge of the line.", + }, + { + name: "stretch", + description: + "If the cross size property of the flex item computes to auto, and neither of the cross-axis margins are auto, the flex item is stretched.", + }, + { + name: "normal", + }, + { + name: "start", + }, + { + name: "end", + }, + { + name: "self-start", + }, + { + name: "self-end", + }, + { + name: "first baseline", + }, + { + name: "last baseline", + }, + { + name: "stretch", + }, + { + name: "safe", + }, + { + name: "unsafe", + }, + ], + syntax: "normal | stretch | | [ ? ]", + relevance: 87, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/align-items", + }, + ], + description: "Aligns flex items along the cross axis of the current line of the flex container.", + restrictions: ["enum"], + }, + { + name: "justify-items", + browsers: ["E12", "FF20", "S9", "C52", "IE11", "O12.1"], + values: [ + { + name: "auto", + }, + { + name: "normal", + }, + { + name: "end", + }, + { + name: "start", + }, + { + name: "flex-end", + description: '"Flex items are packed toward the end of the line."', + }, + { + name: "flex-start", + description: '"Flex items are packed toward the start of the line."', + }, + { + name: "self-end", + description: + "The item is packed flush to the edge of the alignment container of the end side of the item, in the appropriate axis.", + }, + { + name: "self-start", + description: + "The item is packed flush to the edge of the alignment container of the start side of the item, in the appropriate axis..", + }, + { + name: "center", + description: "The items are packed flush to each other toward the center of the of the alignment container.", + }, + { + name: "left", + }, + { + name: "right", + }, + { + name: "baseline", + }, + { + name: "first baseline", + }, + { + name: "last baseline", + }, + { + name: "stretch", + description: + "If the cross size property of the flex item computes to auto, and neither of the cross-axis margins are auto, the flex item is stretched.", + }, + { + name: "safe", + }, + { + name: "unsafe", + }, + { + name: "legacy", + }, + ], + syntax: + "normal | stretch | | ? [ | left | right ] | legacy | legacy && [ left | right | center ]", + relevance: 53, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/justify-items", + }, + ], + description: + "Defines the default justify-self for all items of the box, giving them the default way of justifying each box along the appropriate axis", + restrictions: ["enum"], + }, + { + name: "justify-self", + browsers: ["E16", "FF45", "S10.1", "C57", "IE10", "O44"], + values: [ + { + name: "auto", + }, + { + name: "normal", + }, + { + name: "end", + }, + { + name: "start", + }, + { + name: "flex-end", + description: '"Flex items are packed toward the end of the line."', + }, + { + name: "flex-start", + description: '"Flex items are packed toward the start of the line."', + }, + { + name: "self-end", + description: + "The item is packed flush to the edge of the alignment container of the end side of the item, in the appropriate axis.", + }, + { + name: "self-start", + description: + "The item is packed flush to the edge of the alignment container of the start side of the item, in the appropriate axis..", + }, + { + name: "center", + description: "The items are packed flush to each other toward the center of the of the alignment container.", + }, + { + name: "left", + }, + { + name: "right", + }, + { + name: "baseline", + }, + { + name: "first baseline", + }, + { + name: "last baseline", + }, + { + name: "stretch", + description: + "If the cross size property of the flex item computes to auto, and neither of the cross-axis margins are auto, the flex item is stretched.", + }, + { + name: "save", + }, + { + name: "unsave", + }, + ], + syntax: "auto | normal | stretch | | ? [ | left | right ]", + relevance: 55, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/justify-self", + }, + ], + description: "Defines the way of justifying a box inside its container along the appropriate axis.", + restrictions: ["enum"], + }, + { + name: "align-self", + browsers: ["E12", "FF20", "S9", "C29", "IE10", "O12.1"], + values: [ + { + name: "auto", + description: + "Computes to the value of 'align-items' on the element's parent, or 'stretch' if the element has no parent. On absolutely positioned elements, it computes to itself.", + }, + { + name: "normal", + }, + { + name: "self-end", + }, + { + name: "self-start", + }, + { + name: "baseline", + description: + "If the flex item's inline axis is the same as the cross axis, this value is identical to 'flex-start'. Otherwise, it participates in baseline alignment.", + }, + { + name: "center", + description: "The flex item's margin box is centered in the cross axis within the line.", + }, + { + name: "flex-end", + description: + "The cross-end margin edge of the flex item is placed flush with the cross-end edge of the line.", + }, + { + name: "flex-start", + description: + "The cross-start margin edge of the flex item is placed flush with the cross-start edge of the line.", + }, + { + name: "stretch", + description: + "If the cross size property of the flex item computes to auto, and neither of the cross-axis margins are auto, the flex item is stretched.", + }, + { + name: "baseline", + }, + { + name: "first baseline", + }, + { + name: "last baseline", + }, + { + name: "safe", + }, + { + name: "unsafe", + }, + ], + syntax: "auto | normal | stretch | | ? ", + relevance: 73, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/align-self", + }, + ], + description: "Allows the default alignment along the cross axis to be overridden for individual flex items.", + restrictions: ["enum"], + }, + { + name: "all", + browsers: ["E79", "FF27", "S9.1", "C37", "O24"], + values: [], + syntax: "initial | inherit | unset | revert | revert-layer", + relevance: 53, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/all", + }, + ], + description: "Shorthand that resets all properties except 'direction' and 'unicode-bidi'.", + restrictions: ["enum"], + }, + { + name: "alt", + browsers: ["S9"], + values: [], + relevance: 50, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/alt", + }, + ], + description: + "Provides alternative text for assistive technology to replace the generated content of a ::before or ::after element.", + restrictions: ["string", "enum"], + }, + { + name: "animation", + browsers: ["E12", "FF16", "S9", "C43", "IE10", "O30"], + values: [ + { + name: "alternate", + description: + "The animation cycle iterations that are odd counts are played in the normal direction, and the animation cycle iterations that are even counts are played in a reverse direction.", + }, + { + name: "alternate-reverse", + description: + "The animation cycle iterations that are odd counts are played in the reverse direction, and the animation cycle iterations that are even counts are played in a normal direction.", + }, + { + name: "backwards", + description: + "The beginning property value (as defined in the first @keyframes at-rule) is applied before the animation is displayed, during the period defined by 'animation-delay'.", + }, + { + name: "both", + description: "Both forwards and backwards fill modes are applied.", + }, + { + name: "forwards", + description: + "The final property value (as defined in the last @keyframes at-rule) is maintained after the animation completes.", + }, + { + name: "infinite", + description: "Causes the animation to repeat forever.", + }, + { + name: "none", + description: "No animation is performed", + }, + { + name: "normal", + description: "Normal playback.", + }, + { + name: "reverse", + description: + "All iterations of the animation are played in the reverse direction from the way they were specified.", + }, + ], + syntax: "#", + relevance: 82, + references: [ + { + name: "MDN Reference", + url: "https://developer.mozilla.org/docs/Web/CSS/animation", + }, + ], + description: "Shorthand property combines six of the animation properties into a single property.", + restrictions: ["time", "timing-function", "enum", "identifier", "number"], + }, + { + name: "animation-delay", + browsers: ["E12", "FF16", "S9", "C43", "IE10", "O30"], + syntax: "