From 518fe8f879ddad8a2065838547aef776e25afb66 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Tue, 23 Apr 2024 19:51:05 +0200 Subject: [PATCH 1/7] refactor: rewrite the internals of the language server The internals of the language server are now more uniform. All parts are working on the same parsed data structure instead of being a mix of three different parsers. This refactor includes a private fork of vscode-css-languageservice. It holds the parser and link resolution, and some language features we either extend, modify, or forward. It's kept as a separate package to hopefully make it easier to keep up with upstream, and to send patches upstream as well. A goal with this refactor is to make it easier to maintain the codebase both for myself and for new contributors. --- package-lock.json | 19 + package.json | 5 +- packages/language-server/.nycrc.json | 7 - packages/language-server/package.json | 26 +- packages/language-server/src/constants.ts | 2 - .../language-server/src/context-provider.ts | 29 - .../src/features/code-actions/index.ts | 1 - .../src/features/code-actions/types.ts | 15 - .../features/completion/color-completion.ts | 12 - .../features/completion/completion-context.ts | 220 - .../features/completion/completion-utils.ts | 107 - .../src/features/completion/completion.ts | 363 - .../completion/function-completion.ts | 133 - .../features/completion/import-completion.ts | 179 - .../src/features/completion/index.ts | 1 - .../features/completion/mixin-completion.ts | 178 - .../completion/placeholder-completion.ts | 100 - .../features/completion/sassdoc-completion.ts | 211 - .../completion/variable-completion.ts | 129 - .../features/decorators/color-decorators.ts | 97 - .../src/features/diagnostics/diagnostics.ts | 211 - .../src/features/diagnostics/index.ts | 1 - .../features/go-definition/go-definition.ts | 296 - .../src/features/go-definition/index.ts | 1 - .../src/features/hover/hover.ts | 425 - .../src/features/hover/index.ts | 1 - .../src/features/references/index.ts | 1 - .../src/features/references/references.ts | 417 - .../src/features/rename/index.ts | 1 - .../src/features/rename/rename.ts | 129 - .../src/features/signature-help/facts.ts | 91 - .../src/features/signature-help/index.ts | 1 - .../features/signature-help/signature-help.ts | 360 - .../src/features/workspace-symbols/index.ts | 1 - .../workspace-symbols/workspace-symbol.ts | 38 - .../src/file-system-provider.ts | 4 +- packages/language-server/src/file-system.ts | 4 +- .../language-server/src/node-file-system.ts | 14 +- packages/language-server/src/parser/ast.ts | 59 - .../language-server/src/parser/document.ts | 36 - packages/language-server/src/parser/index.ts | 5 - .../src/parser/language-service.ts | 46 - packages/language-server/src/parser/node.ts | 112 - packages/language-server/src/parser/parser.ts | 438 - .../src/parser/scss-document.ts | 168 - .../language-server/src/parser/scss-symbol.ts | 89 - .../language-server/src/parser/tokenizer.ts | 28 - packages/language-server/src/scanner.ts | 108 - packages/language-server/src/server.ts | 613 +- packages/language-server/src/storage.ts | 48 - .../utils/__tests__/embedded.test.ts} | 121 +- .../language-server/src/utils/embedded.ts | 33 +- packages/language-server/src/utils/scss.ts | 60 - packages/language-server/src/utils/string.ts | 118 - .../language-server/src/workspace-scanner.ts | 91 + packages/language-server/test/.eslintrc.json | 8 - .../features/code-actions/extract.spec.ts | 347 - .../features/completion-visibility.spec.ts | 89 - .../test/features/completion.spec.ts | 326 - .../test/features/diagnostics.spec.ts | 213 - .../test/features/go-definition.spec.ts | 151 - .../test/features/hover.spec.ts | 143 - .../test/features/references.spec.ts | 999 - .../test/features/signature-help.spec.ts | 299 - .../test/features/workspace-symbol.spec.ts | 113 - .../language-server/test/fixture-helper.ts | 10 - .../multi-level-hide/colors/_index.scss | 1 - .../multi-level-hide/colors/base/_base.scss | 3 - .../multi-level-hide/colors/base/_index.scss | 1 - .../completion/multi-level-hide/styles.scss | 3 - .../same-symbol-name-hide/colors/_index.scss | 2 - .../colors/branch-a/_base.scss | 1 - .../colors/branch-a/_index.scss | 1 - .../colors/branch-b/_base.scss | 1 - .../colors/branch-b/_index.scss | 1 - .../same-symbol-name-hide/styles.scss | 3 - .../same-symbol-name-show/colors/_index.scss | 2 - .../colors/branch-a/_base.scss | 3 - .../colors/branch-a/_index.scss | 1 - .../colors/branch-b/_base.scss | 3 - .../colors/branch-b/_index.scss | 1 - .../same-symbol-name-show/styles.scss | 3 - .../fixtures/multi-root/foldera/testA.scss | 5 - .../fixtures/multi-root/folderb/testB.scss | 3 - .../multi-root/multi-root.code-workspace | 19 - .../follow-links/namespace/_index.scss | 1 - .../follow-links/namespace/_variables.scss | 1 - .../fixtures/scanner/follow-links/styles.scss | 1 - .../scanner/self-reference/styles.scss | 3 - packages/language-server/test/helpers.ts | 88 - .../language-server/test/parser/ast.spec.ts | 27 - .../test/parser/cssNodes.spec.ts | 20 - .../test/parser/parser.spec.ts | 422 - .../test/scanner/scanner.spec.ts | 57 - .../language-server/test/test-file-system.ts | 74 - .../test/utils/sassdoc.spec.ts | 259 - .../language-server/test/utils/string.spec.ts | 42 - packages/language-server/vitest.config.mts | 10 + packages/language-server/webpack.config.js | 1 + packages/language-services/README.md | 3 + packages/language-services/package.json | 66 + .../src/facts/sass.ts} | 0 .../src/facts/sassdoc.ts} | 2 +- .../__tests__/code-actions-extract.test.ts | 260 + .../__tests__/do-complete-embedded.test.ts | 134 + .../__tests__/do-complete-import.test.ts | 69 + .../__tests__/do-complete-modules.test.ts | 938 + .../do-complete-placeholders.test.ts | 37 + .../__tests__/do-complete-sassdoc.test.ts | 245 + .../features/__tests__/do-complete.test.ts | 552 + .../do-diagnostics-deprecation.test.ts | 288 + .../src/features/__tests__/do-hover.test.ts | 195 + .../__tests__/do-rename-perform.test.ts | 212 + .../__tests__/do-rename-prepare.test.ts | 111 + .../__tests__/do-signature-help.test.ts | 497 + .../features/__tests__/find-colors.test.ts | 44 + .../__tests__/find-definition.test.ts | 285 + .../__tests__/find-document-links.test.ts | 66 + .../__tests__/find-references.test.ts | 824 + .../__tests__/find-symbols-document.test.ts | 208 + .../__tests__/find-symbols-workspace.test.ts | 68 + .../src/features/code-actions.ts} | 88 +- .../src/features/do-complete.ts | 1501 ++ .../src/features/do-diagnostics.ts | 108 + .../src/features/do-hover.ts | 382 + .../src/features/do-rename.ts | 126 + .../src/features/do-signature-help.ts | 178 + .../src/features/find-colors.ts | 92 + .../src/features/find-definition.ts | 154 + .../src/features/find-document-highlights.ts | 20 + .../src/features/find-document-links.ts | 34 + .../src/features/find-references.ts | 553 + .../src/features/find-symbols.ts | 87 + .../language-services/src/language-feature.ts | 363 + .../src/language-model-cache.ts | 204 + .../src/language-services-types.ts | 433 + .../src/language-services.ts | 194 + .../language-services/src/utils/arrays.ts | 42 + .../src/utils/fs-provider.ts | 34 + .../language-services/src/utils/objects.ts | 12 + .../language-services/src/utils/resources.ts | 13 + packages/language-services/src/utils/sass.ts | 4 + .../src/utils/sassdoc.ts | 4 +- .../language-services/src/utils/strings.ts | 110 + .../src/utils/test-helpers.ts | 137 + packages/language-services/tsconfig.json | 15 + packages/language-services/vitest.config.mts | 10 + .../vscode-css-languageservice/.editorconfig | 5 + .../vscode-css-languageservice/.eslintrc.json | 27 + .../vscode-css-languageservice/.gitignore | 5 + .../vscode-css-languageservice/.mocharc.json | 6 + .../vscode-css-languageservice/.npmignore | 19 + .../vscode-css-languageservice/.prettierrc | 5 + .../vscode-css-languageservice/LICENSE.md | 21 + packages/vscode-css-languageservice/README.md | 9 + .../build/generateData.js | 33 + .../build/remove-sourcemap-refs.js | 32 + .../docs/customData.md | 110 + .../docs/customData.schema.json | 245 + .../package-lock.json | 2760 +++ .../vscode-css-languageservice/package.json | 50 + .../src/cssLanguageService.ts | 199 + .../src/cssLanguageTypes.ts | 404 + .../src/data/webCustomData.ts | 20354 ++++++++++++++++ .../src/languageFacts/builtinData.ts | 398 + .../src/languageFacts/colors.ts | 645 + .../src/languageFacts/dataManager.ts | 119 + .../src/languageFacts/dataProvider.ts | 90 + .../src/languageFacts/entry.ts | 213 + .../src/languageFacts/facts.ts | 9 + .../src/parser/cssErrors.ts | 58 + .../src/parser/cssNodes.ts | 2024 ++ .../src/parser/cssParser.ts | 2036 ++ .../src/parser/cssScanner.ts | 682 + .../src/parser/cssSymbolScope.ts | 379 + .../src/parser/scssErrors.ts | 25 + .../src/parser/scssParser.ts | 992 + .../src/parser/scssScanner.ts | 107 + .../src/services/cssCodeActions.ts | 130 + .../src/services/cssCompletion.ts | 1293 + .../src/services/cssFolding.ts | 210 + .../src/services/cssHover.ts | 200 + .../src/services/cssNavigation.ts | 743 + .../src/services/cssSelectionRange.ts | 62 + .../src/services/cssValidation.ts | 66 + .../src/services/lint.ts | 669 + .../src/services/lintRules.ts | 134 + .../src/services/lintUtil.ts | 244 + .../src/services/pathCompletion.ts | 201 + .../src/services/scssCompletion.ts | 479 + .../src/services/scssNavigation.ts | 77 + .../src/services/selectorPrinting.ts | 633 + .../src/test/css/codeActions.test.ts | 89 + .../src/test/css/completion.test.ts | 1173 + .../src/test/css/customData.test.ts | 155 + .../src/test/css/folding.test.ts | 295 + .../src/test/css/hover.test.ts | 120 + .../src/test/css/languageFacts.test.ts | 265 + .../src/test/css/lint.test.ts | 399 + .../src/test/css/navigation.test.ts | 682 + .../src/test/css/nodes.test.ts | 207 + .../src/test/css/parser.test.ts | 961 + .../src/test/css/scanner.test.ts | 248 + .../src/test/css/selectionRange.test.ts | 137 + .../src/test/css/selectorPrinting.test.ts | 383 + .../src/test/scss/example.scss | 336 + .../src/test/scss/languageFacts.test.ts | 25 + .../src/test/scss/linkFixture/both/_foo.scss | 0 .../src/test/scss/linkFixture/both/foo.scss | 0 .../scss/linkFixture/index/bar/_index.scss | 0 .../scss/linkFixture/index/foo/index.scss | 0 .../src/test/scss/linkFixture/module/foo.scss | 0 .../scss/linkFixture/noUnderscore/foo.scss | 0 .../scss/linkFixture/underscore/_foo.scss | 0 .../src/test/scss/lint.test.ts | 77 + .../src/test/scss/parser.test.ts | 1151 + .../src/test/scss/scssCompletion.test.ts | 326 + .../src/test/scss/scssNavigation.test.ts | 544 + .../src/test/scss/selectorPrinting.test.ts | 47 + .../src/test/testUtil/documentContext.ts | 25 + .../src/test/testUtil/fsProvider.ts | 79 + .../src/test/util.test.ts | 17 + .../src/tsconfig.esm.json | 15 + .../src/tsconfig.json | 15 + .../src/utils/arrays.ts | 43 + .../src/utils/objects.ts | 13 + .../src/utils/resources.ts | 14 + .../src/utils/strings.ts | 107 + .../test/linksTestFixtures/.gitignore | 1 + .../test/linksTestFixtures/a.css | 0 .../test/linksTestFixtures/green/c.css | 0 .../test/linksTestFixtures/green/d.scss | 0 .../node_modules/@foo/bar/_baz.scss | 1 + .../node_modules/@foo/bar/_index.scss | 1 + .../node_modules/@foo/bar/package.json | 1 + .../node_modules/foo/package.json | 1 + .../node_modules/green/_e.scss | 0 .../node_modules/green/c.css | 0 .../node_modules/green/d.scss | 0 .../node_modules/green/package.json | 1 + .../test/pathCompletionFixtures/.foo.js | 4 + .../pathCompletionFixtures/about/about.css | 4 + .../pathCompletionFixtures/about/about.html | 0 .../test/pathCompletionFixtures/index.html | 0 .../pathCompletionFixtures/scss/_foo.scss | 4 + .../pathCompletionFixtures/scss/main.scss | 4 + .../pathCompletionFixtures/src/data/foo.asar | 4 + .../pathCompletionFixtures/src/feature.js | 4 + .../test/pathCompletionFixtures/src/test.js | 4 + 249 files changed, 55699 insertions(+), 9251 deletions(-) delete mode 100644 packages/language-server/.nycrc.json delete mode 100644 packages/language-server/src/context-provider.ts delete mode 100644 packages/language-server/src/features/code-actions/index.ts delete mode 100644 packages/language-server/src/features/code-actions/types.ts delete mode 100644 packages/language-server/src/features/completion/color-completion.ts delete mode 100644 packages/language-server/src/features/completion/completion-context.ts delete mode 100644 packages/language-server/src/features/completion/completion-utils.ts delete mode 100644 packages/language-server/src/features/completion/completion.ts delete mode 100644 packages/language-server/src/features/completion/function-completion.ts delete mode 100644 packages/language-server/src/features/completion/import-completion.ts delete mode 100644 packages/language-server/src/features/completion/index.ts delete mode 100644 packages/language-server/src/features/completion/mixin-completion.ts delete mode 100644 packages/language-server/src/features/completion/placeholder-completion.ts delete mode 100644 packages/language-server/src/features/completion/sassdoc-completion.ts delete mode 100644 packages/language-server/src/features/completion/variable-completion.ts delete mode 100644 packages/language-server/src/features/decorators/color-decorators.ts delete mode 100644 packages/language-server/src/features/diagnostics/diagnostics.ts delete mode 100644 packages/language-server/src/features/diagnostics/index.ts delete mode 100644 packages/language-server/src/features/go-definition/go-definition.ts delete mode 100644 packages/language-server/src/features/go-definition/index.ts delete mode 100644 packages/language-server/src/features/hover/hover.ts delete mode 100644 packages/language-server/src/features/hover/index.ts delete mode 100644 packages/language-server/src/features/references/index.ts delete mode 100644 packages/language-server/src/features/references/references.ts delete mode 100644 packages/language-server/src/features/rename/index.ts delete mode 100644 packages/language-server/src/features/rename/rename.ts delete mode 100644 packages/language-server/src/features/signature-help/facts.ts delete mode 100644 packages/language-server/src/features/signature-help/index.ts delete mode 100644 packages/language-server/src/features/signature-help/signature-help.ts delete mode 100644 packages/language-server/src/features/workspace-symbols/index.ts delete mode 100644 packages/language-server/src/features/workspace-symbols/workspace-symbol.ts delete mode 100644 packages/language-server/src/parser/ast.ts delete mode 100644 packages/language-server/src/parser/document.ts delete mode 100644 packages/language-server/src/parser/index.ts delete mode 100644 packages/language-server/src/parser/language-service.ts delete mode 100644 packages/language-server/src/parser/node.ts delete mode 100644 packages/language-server/src/parser/parser.ts delete mode 100644 packages/language-server/src/parser/scss-document.ts delete mode 100644 packages/language-server/src/parser/scss-symbol.ts delete mode 100644 packages/language-server/src/parser/tokenizer.ts delete mode 100644 packages/language-server/src/scanner.ts delete mode 100644 packages/language-server/src/storage.ts rename packages/language-server/{test/utils/embedded.spec.ts => src/utils/__tests__/embedded.test.ts} (64%) delete mode 100644 packages/language-server/src/utils/scss.ts delete mode 100644 packages/language-server/src/utils/string.ts create mode 100644 packages/language-server/src/workspace-scanner.ts delete mode 100644 packages/language-server/test/.eslintrc.json delete mode 100644 packages/language-server/test/features/code-actions/extract.spec.ts delete mode 100644 packages/language-server/test/features/completion-visibility.spec.ts delete mode 100644 packages/language-server/test/features/completion.spec.ts delete mode 100644 packages/language-server/test/features/diagnostics.spec.ts delete mode 100644 packages/language-server/test/features/go-definition.spec.ts delete mode 100644 packages/language-server/test/features/hover.spec.ts delete mode 100644 packages/language-server/test/features/references.spec.ts delete mode 100644 packages/language-server/test/features/signature-help.spec.ts delete mode 100644 packages/language-server/test/features/workspace-symbol.spec.ts delete mode 100644 packages/language-server/test/fixture-helper.ts delete mode 100644 packages/language-server/test/fixtures/completion/multi-level-hide/colors/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_base.scss delete mode 100644 packages/language-server/test/fixtures/completion/multi-level-hide/colors/base/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/multi-level-hide/styles.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_base.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-a/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_base.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-hide/colors/branch-b/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-hide/styles.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_base.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-a/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_base.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-show/colors/branch-b/_index.scss delete mode 100644 packages/language-server/test/fixtures/completion/same-symbol-name-show/styles.scss delete mode 100644 packages/language-server/test/fixtures/multi-root/foldera/testA.scss delete mode 100644 packages/language-server/test/fixtures/multi-root/folderb/testB.scss delete mode 100644 packages/language-server/test/fixtures/multi-root/multi-root.code-workspace delete mode 100644 packages/language-server/test/fixtures/scanner/follow-links/namespace/_index.scss delete mode 100644 packages/language-server/test/fixtures/scanner/follow-links/namespace/_variables.scss delete mode 100644 packages/language-server/test/fixtures/scanner/follow-links/styles.scss delete mode 100644 packages/language-server/test/fixtures/scanner/self-reference/styles.scss delete mode 100644 packages/language-server/test/helpers.ts delete mode 100644 packages/language-server/test/parser/ast.spec.ts delete mode 100644 packages/language-server/test/parser/cssNodes.spec.ts delete mode 100644 packages/language-server/test/parser/parser.spec.ts delete mode 100644 packages/language-server/test/scanner/scanner.spec.ts delete mode 100644 packages/language-server/test/test-file-system.ts delete mode 100644 packages/language-server/test/utils/sassdoc.spec.ts delete mode 100644 packages/language-server/test/utils/string.spec.ts create mode 100644 packages/language-server/vitest.config.mts create mode 100644 packages/language-services/README.md create mode 100644 packages/language-services/package.json rename packages/{language-server/src/features/sass-built-in-modules.ts => language-services/src/facts/sass.ts} (100%) rename packages/{language-server/src/features/sassdoc-annotations.ts => language-services/src/facts/sassdoc.ts} (97%) create mode 100644 packages/language-services/src/features/__tests__/code-actions-extract.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-complete-embedded.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-complete-import.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-complete-modules.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-complete-placeholders.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-complete-sassdoc.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-complete.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-diagnostics-deprecation.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-hover.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-rename-perform.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-rename-prepare.test.ts create mode 100644 packages/language-services/src/features/__tests__/do-signature-help.test.ts create mode 100644 packages/language-services/src/features/__tests__/find-colors.test.ts create mode 100644 packages/language-services/src/features/__tests__/find-definition.test.ts create mode 100644 packages/language-services/src/features/__tests__/find-document-links.test.ts create mode 100644 packages/language-services/src/features/__tests__/find-references.test.ts create mode 100644 packages/language-services/src/features/__tests__/find-symbols-document.test.ts create mode 100644 packages/language-services/src/features/__tests__/find-symbols-workspace.test.ts rename packages/{language-server/src/features/code-actions/extract-provider.ts => language-services/src/features/code-actions.ts} (71%) create mode 100644 packages/language-services/src/features/do-complete.ts create mode 100644 packages/language-services/src/features/do-diagnostics.ts create mode 100644 packages/language-services/src/features/do-hover.ts create mode 100644 packages/language-services/src/features/do-rename.ts create mode 100644 packages/language-services/src/features/do-signature-help.ts create mode 100644 packages/language-services/src/features/find-colors.ts create mode 100644 packages/language-services/src/features/find-definition.ts create mode 100644 packages/language-services/src/features/find-document-highlights.ts create mode 100644 packages/language-services/src/features/find-document-links.ts create mode 100644 packages/language-services/src/features/find-references.ts create mode 100644 packages/language-services/src/features/find-symbols.ts create mode 100644 packages/language-services/src/language-feature.ts create mode 100644 packages/language-services/src/language-model-cache.ts create mode 100644 packages/language-services/src/language-services-types.ts create mode 100644 packages/language-services/src/language-services.ts create mode 100644 packages/language-services/src/utils/arrays.ts create mode 100644 packages/language-services/src/utils/fs-provider.ts create mode 100644 packages/language-services/src/utils/objects.ts create mode 100644 packages/language-services/src/utils/resources.ts create mode 100644 packages/language-services/src/utils/sass.ts rename packages/{language-server => language-services}/src/utils/sassdoc.ts (96%) create mode 100644 packages/language-services/src/utils/strings.ts create mode 100644 packages/language-services/src/utils/test-helpers.ts create mode 100644 packages/language-services/tsconfig.json create mode 100644 packages/language-services/vitest.config.mts create mode 100644 packages/vscode-css-languageservice/.editorconfig create mode 100644 packages/vscode-css-languageservice/.eslintrc.json create mode 100644 packages/vscode-css-languageservice/.gitignore create mode 100644 packages/vscode-css-languageservice/.mocharc.json create mode 100644 packages/vscode-css-languageservice/.npmignore create mode 100644 packages/vscode-css-languageservice/.prettierrc create mode 100644 packages/vscode-css-languageservice/LICENSE.md create mode 100644 packages/vscode-css-languageservice/README.md create mode 100644 packages/vscode-css-languageservice/build/generateData.js create mode 100644 packages/vscode-css-languageservice/build/remove-sourcemap-refs.js create mode 100644 packages/vscode-css-languageservice/docs/customData.md create mode 100644 packages/vscode-css-languageservice/docs/customData.schema.json create mode 100644 packages/vscode-css-languageservice/package-lock.json create mode 100644 packages/vscode-css-languageservice/package.json create mode 100644 packages/vscode-css-languageservice/src/cssLanguageService.ts create mode 100644 packages/vscode-css-languageservice/src/cssLanguageTypes.ts create mode 100644 packages/vscode-css-languageservice/src/data/webCustomData.ts create mode 100644 packages/vscode-css-languageservice/src/languageFacts/builtinData.ts create mode 100644 packages/vscode-css-languageservice/src/languageFacts/colors.ts create mode 100644 packages/vscode-css-languageservice/src/languageFacts/dataManager.ts create mode 100644 packages/vscode-css-languageservice/src/languageFacts/dataProvider.ts create mode 100644 packages/vscode-css-languageservice/src/languageFacts/entry.ts create mode 100644 packages/vscode-css-languageservice/src/languageFacts/facts.ts create mode 100644 packages/vscode-css-languageservice/src/parser/cssErrors.ts create mode 100644 packages/vscode-css-languageservice/src/parser/cssNodes.ts create mode 100644 packages/vscode-css-languageservice/src/parser/cssParser.ts create mode 100644 packages/vscode-css-languageservice/src/parser/cssScanner.ts create mode 100644 packages/vscode-css-languageservice/src/parser/cssSymbolScope.ts create mode 100644 packages/vscode-css-languageservice/src/parser/scssErrors.ts create mode 100644 packages/vscode-css-languageservice/src/parser/scssParser.ts create mode 100644 packages/vscode-css-languageservice/src/parser/scssScanner.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssCodeActions.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssCompletion.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssFolding.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssHover.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssNavigation.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssSelectionRange.ts create mode 100644 packages/vscode-css-languageservice/src/services/cssValidation.ts create mode 100644 packages/vscode-css-languageservice/src/services/lint.ts create mode 100644 packages/vscode-css-languageservice/src/services/lintRules.ts create mode 100644 packages/vscode-css-languageservice/src/services/lintUtil.ts create mode 100644 packages/vscode-css-languageservice/src/services/pathCompletion.ts create mode 100644 packages/vscode-css-languageservice/src/services/scssCompletion.ts create mode 100644 packages/vscode-css-languageservice/src/services/scssNavigation.ts create mode 100644 packages/vscode-css-languageservice/src/services/selectorPrinting.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/codeActions.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/completion.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/customData.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/folding.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/hover.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/languageFacts.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/lint.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/navigation.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/nodes.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/parser.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/scanner.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/selectionRange.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/css/selectorPrinting.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/scss/example.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/languageFacts.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/both/_foo.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/both/foo.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/index/bar/_index.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/index/foo/index.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/module/foo.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/noUnderscore/foo.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/linkFixture/underscore/_foo.scss create mode 100644 packages/vscode-css-languageservice/src/test/scss/lint.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/scss/parser.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/scss/scssCompletion.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/scss/scssNavigation.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/scss/selectorPrinting.test.ts create mode 100644 packages/vscode-css-languageservice/src/test/testUtil/documentContext.ts create mode 100644 packages/vscode-css-languageservice/src/test/testUtil/fsProvider.ts create mode 100644 packages/vscode-css-languageservice/src/test/util.test.ts create mode 100644 packages/vscode-css-languageservice/src/tsconfig.esm.json create mode 100644 packages/vscode-css-languageservice/src/tsconfig.json create mode 100644 packages/vscode-css-languageservice/src/utils/arrays.ts create mode 100644 packages/vscode-css-languageservice/src/utils/objects.ts create mode 100644 packages/vscode-css-languageservice/src/utils/resources.ts create mode 100644 packages/vscode-css-languageservice/src/utils/strings.ts create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/.gitignore create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/a.css create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/green/c.css create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/green/d.scss create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/@foo/bar/_baz.scss create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/@foo/bar/_index.scss create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/@foo/bar/package.json create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/foo/package.json create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/green/_e.scss create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/green/c.css create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/green/d.scss create mode 100644 packages/vscode-css-languageservice/test/linksTestFixtures/node_modules/green/package.json create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/.foo.js create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/about/about.css create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/about/about.html create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/index.html create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/scss/_foo.scss create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/scss/main.scss create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/src/data/foo.asar create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/src/feature.js create mode 100644 packages/vscode-css-languageservice/test/pathCompletionFixtures/src/test.js diff --git a/package-lock.json b/package-lock.json index 92e7c869..ddc57845 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@typescript-eslint/eslint-plugin": "7.5.0", "@typescript-eslint/parser": "7.5.0", "copy-webpack-plugin": "12.0.2", + "cross-env": "7.0.3", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "2.29.1", @@ -9308,6 +9309,24 @@ "typescript": ">=4" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/package.json b/package.json index d56179a5..5a7583b1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "build": "npm run build --workspaces --if-present", "lint": "eslint \"**/*.ts\" --cache", "lint-staged": "lint-staged", - "test": "npm run test --workspaces --if-present", + "start:web": "npm run --workspace=some-sass start:web", + "test": "cross-env CI=true npm run test --workspaces --if-present", + "test:all": "npm run test && npm run test:e2e && npm run test:web", "test:e2e": "npm run test:e2e --workspaces --if-present", "test:web": "npm run test:web --workspaces --if-present" }, @@ -34,6 +36,7 @@ "@typescript-eslint/eslint-plugin": "7.5.0", "@typescript-eslint/parser": "7.5.0", "copy-webpack-plugin": "12.0.2", + "cross-env": "7.0.3", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-import": "2.29.1", diff --git a/packages/language-server/.nycrc.json b/packages/language-server/.nycrc.json deleted file mode 100644 index f84854c1..00000000 --- a/packages/language-server/.nycrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "reporter": ["text", "html"], - "all": true, - "check-coverage": true, - "include": ["src/**", "out/**"], - "exclude": ["**/*.d.ts", "out/test/**", "src/test/**"] -} diff --git a/packages/language-server/package.json b/packages/language-server/package.json index b6fdd923..0a3ee329 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -44,35 +44,23 @@ "author": "William Killerud (https://www.williamkillerud.com/)", "license": "MIT", "scripts": { - "build": "npm run clean && webpack --mode production && webpack --mode development", + "build": "webpack --mode production && webpack --mode development", "clean": "shx rm -rf dist", - "test": "npm run test:clean && npm run test:build && npm run test:unit", - "test:clean": "shx rm -rf out", - "test:build": "tsc -p ./ && shx cp -r ./test/fixtures ./out/test", - "test:unit": "mocha \"out/**/*.spec.js\"", - "test:coverage": "nyc npm run test:unit" + "test": "vitest", + "coverage": "vitest run --coverage" }, "devDependencies": { - "@nodelib/fs.macchiato": "2.0.0", - "@types/micromatch": "4.0.6", - "@types/mocha": "10.0.6", + "@vitest/coverage-v8": "1.3.1", + "@somesass/language-services": "1.0.0", "@types/node": "20.12.2", - "@types/sinon": "17.0.3", - "assert": "2.1.0", - "colorjs.io": "0.5.0", "fast-glob": "3.3.2", "merge-options": "3.0.4", - "micromatch": "4.0.5", - "mocha": "10.4.0", - "nyc": "15.1.0", "path-browserify": "1.0.1", "process": "0.11.10", - "scss-sassdoc-parser": "3.1.0", - "scss-symbols-parser": "2.0.1", "shx": "0.3.4", - "sinon": "17.0.1", "util": "0.12.5", - "vscode-css-languageservice": "6.2.13", + "url": "0.11.3", + "vitest": "1.5.0", "vscode-languageserver": "9.0.1", "vscode-languageserver-textdocument": "1.0.11", "vscode-languageserver-types": "3.17.5", diff --git a/packages/language-server/src/constants.ts b/packages/language-server/src/constants.ts index b1b3a72a..11456216 100644 --- a/packages/language-server/src/constants.ts +++ b/packages/language-server/src/constants.ts @@ -1,6 +1,4 @@ export const EXTENSION_ID = "some-sass"; -export const EXTENSION_NAME = "Some Sass"; - export const REQUEST_FS_STAT = `${EXTENSION_ID}/stat`; export const REQUEST_FS_FIND_FILES = `${EXTENSION_ID}/find-files`; export const REQUEST_FS_READ_FILE = `${EXTENSION_ID}/read-file`; diff --git a/packages/language-server/src/context-provider.ts b/packages/language-server/src/context-provider.ts deleted file mode 100644 index ddeab4e6..00000000 --- a/packages/language-server/src/context-provider.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ClientCapabilities } from "vscode-css-languageservice"; -import { URI } from "vscode-uri"; -import { FileSystemProvider } from "./file-system"; -import { IEditorSettings, ISettings } from "./settings"; -import StorageService from "./storage"; - -export type SomeSassContext = { - workspaceRoot: URI; - clientCapabilities: ClientCapabilities; - fs: FileSystemProvider; - settings: ISettings; - editorSettings: IEditorSettings; - storage: StorageService; -}; - -let context: SomeSassContext; - -export const createContext = (ctx: SomeSassContext): void => { - context = ctx; -}; - -export const changeConfiguration = (settings: ISettings): void => { - if (!context) { - return; - } - context.settings = settings; -}; - -export const useContext = (): SomeSassContext => context; diff --git a/packages/language-server/src/features/code-actions/index.ts b/packages/language-server/src/features/code-actions/index.ts deleted file mode 100644 index e5403401..00000000 --- a/packages/language-server/src/features/code-actions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./extract-provider"; diff --git a/packages/language-server/src/features/code-actions/types.ts b/packages/language-server/src/features/code-actions/types.ts deleted file mode 100644 index e45dd9be..00000000 --- a/packages/language-server/src/features/code-actions/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { TextDocument } from "vscode-languageserver-textdocument"; -import type { - CodeAction, - CodeActionContext, - Range, -} from "vscode-languageserver-types"; - -export interface CodeActionProvider { - provideCodeActions: ( - document: TextDocument, - range: Range, - context: CodeActionContext, - ) => Promise; - resolveCodeAction?: (codeAction: CodeAction) => Promise; -} diff --git a/packages/language-server/src/features/completion/color-completion.ts b/packages/language-server/src/features/completion/color-completion.ts deleted file mode 100644 index 3a1a05bb..00000000 --- a/packages/language-server/src/features/completion/color-completion.ts +++ /dev/null @@ -1,12 +0,0 @@ -import ColorDotJS from "colorjs.io"; - -export function isColor(value: string): string | null { - try { - ColorDotJS.parse(value); - // Yup, it's color. - return value; - } catch (e) { - return null; - } - return null; -} diff --git a/packages/language-server/src/features/completion/completion-context.ts b/packages/language-server/src/features/completion/completion-context.ts deleted file mode 100644 index 05efecac..00000000 --- a/packages/language-server/src/features/completion/completion-context.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { TextDocument } from "vscode-languageserver-textdocument"; -import type { ISettings } from "../../settings"; -import { getCurrentWord, getTextBeforePosition } from "../../utils/string"; - -type SupportedExtensions = "astro" | "scss" | "svelte" | "vue"; - -export interface CompletionContext { - word: string; - textBeforeWord: string; - comment: boolean; - sassDoc: boolean; - namespace: string | null; - import: boolean; - variable: boolean; - function: boolean; - mixin: boolean; - originalExtension: SupportedExtensions; - placeholder: boolean; - placeholderDeclaration: boolean; -} - -const reReturn = /^.*@return/; -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 rePartialModuleAtRule = /@(?:use|forward|import) ["']/; - -/** - * Check context for Variables suggestions. - */ -function checkVariableContext( - word: string, - isInterpolation: boolean, - isPropertyValue: boolean, - isEmptyValue: boolean, - isQuotes: boolean, - isReturn: boolean, - isNamespace: boolean, -): boolean { - if ((isReturn || isPropertyValue) && !isEmptyValue && !isQuotes) { - if (isNamespace && word.endsWith(".")) { - return true; - } - - return word.includes("$"); - } - - if (isQuotes) { - return isInterpolation; - } - - return word.startsWith("$") || isInterpolation || isEmptyValue; -} - -/** - * Check context for Mixins suggestions. - */ -function checkMixinContext( - textBeforeWord: string, - isPropertyValue: boolean, -): boolean { - return !isPropertyValue && reMixinReference.test(textBeforeWord); -} - -/** - * Check context for Function suggestions. - */ -function checkFunctionContext( - textBeforeWord: string, - isInterpolation: boolean, - isPropertyValue: boolean, - isEmptyValue: boolean, - isQuotes: boolean, - isReturn: boolean, - isNamespace: boolean, - settings: ISettings, -): boolean { - if ((isReturn || isPropertyValue) && !isEmptyValue && !isQuotes) { - if (isNamespace) { - return true; - } - - const lastChar = textBeforeWord.slice(-2, -2 + 1); - return settings.suggestFunctionsInStringContextAfterSymbols.includes( - lastChar, - ); - } - - if (isQuotes) { - return isInterpolation; - } - - return false; -} - -function isCommentContext(text: string): boolean { - return reComment.test(text.trim()); -} - -function isSassDocContext(text: string): boolean { - return reSassDoc.test(text); -} - -function isInterpolationContext(text: string): boolean { - return text.includes("#{"); -} - -function checkNamespaceContext( - currentWord: string, - isInterpolation: boolean, -): string | null { - if (currentWord.length === 0 || !currentWord.includes(".")) { - return null; - } - - // Skip #{ if this is interpolation - return currentWord.substring( - isInterpolation ? currentWord.indexOf("{") + 1 : 0, - currentWord.indexOf("."), - ); -} - -export function createCompletionContext( - document: TextDocument, - text: string, - offset: number, - settings: ISettings, -): CompletionContext { - const word = getCurrentWord(text, offset); - const textBeforeWord = getTextBeforePosition(text, offset); - const lastDot = document.uri.lastIndexOf("."); - const originalExtension = document.uri.slice( - Math.max(0, lastDot + 1), - ) as SupportedExtensions; - - const result: CompletionContext = { - word, - textBeforeWord, - originalExtension, - comment: false, - sassDoc: false, - namespace: null, - import: false, - variable: false, - function: false, - mixin: false, - placeholder: false, - placeholderDeclaration: false, - }; - - result.import = rePartialModuleAtRule.test(textBeforeWord); - if (result.import) { - return result; - } - - result.comment = isCommentContext(textBeforeWord); - result.sassDoc = isSassDocContext(textBeforeWord); - if (result.comment || result.sassDoc) { - return result; - } - - // Is "#{INTERPOLATION}" - const isInterpolation = isInterpolationContext(word); - // Is namespace, e.g. `namespace.$var` or `@include namespace.mixin` or `namespace.func()` - result.namespace = checkNamespaceContext(word, isInterpolation); - - // Information about current position - const isReturn = reReturn.test(textBeforeWord); - const isPropertyValue = rePropertyValue.test(textBeforeWord); - const isEmptyValue = reEmptyPropertyValue.test(textBeforeWord); - const isQuotes = reQuotes.test( - textBeforeWord.replace(reQuotedValueInString, ""), - ); - - result.variable = checkVariableContext( - word, - isInterpolation, - isPropertyValue, - isEmptyValue, - isQuotes, - isReturn, - Boolean(result.namespace), - ); - result.function = checkFunctionContext( - textBeforeWord, - isInterpolation, - isPropertyValue, - isEmptyValue, - isQuotes, - isReturn, - Boolean(result.namespace), - settings, - ); - result.mixin = checkMixinContext(textBeforeWord, isPropertyValue); - - if (result.variable || result.function || result.mixin) { - return result; - } - - // Is placeholder, e.g. `@extend %placeholder` - result.placeholder = rePlaceholder.test(textBeforeWord); - if (result.placeholder) { - return result; - } - - result.placeholderDeclaration = - !result.placeholder && - (/\s+%/.test(textBeforeWord) || /^%/.test(textBeforeWord)); - - if (result.placeholderDeclaration) { - return result; - } - - return result; -} diff --git a/packages/language-server/src/features/completion/completion-utils.ts b/packages/language-server/src/features/completion/completion-utils.ts deleted file mode 100644 index 80f93e5e..00000000 --- a/packages/language-server/src/features/completion/completion-utils.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { MarkupContent, MarkupKind } from "vscode-languageserver"; -import type { - IScssDocument, - ScssFunction, - ScssMixin, - ScssParameter, -} from "../../parser"; -import { applySassDoc } from "../../utils/sassdoc"; -import { asDollarlessVariable } from "../../utils/string"; - -export const rePrivate = /^\$?[_-].*$/; - -export function makeMixinDocumentation( - mixin: ScssMixin, - sourceDocument: IScssDocument, -): MarkupContent { - const args = mixin.parameters - .map((item) => `${item.name}: ${item.value}`) - .join(", "); - - const result = { - kind: MarkupKind.Markdown, - value: ["```scss", `@mixin ${mixin.name}(${args})`, "```"].join("\n"), - }; - - const sassdoc = applySassDoc(mixin); - if (sassdoc) { - result.value += `\n____\n${sassdoc}`; - } - - result.value += `\n____\nMixin declared in ${sourceDocument.fileName}`; - - return result; -} - -export function makeFunctionDocumentation( - func: ScssFunction, - sourceDocument: IScssDocument, -): MarkupContent { - const args = func.parameters - .map((item) => `${item.name}: ${item.value}`) - .join(", "); - - const result = { - kind: MarkupKind.Markdown, - value: ["```scss", `@function ${func.name}(${args})`, "```"].join("\n"), - }; - - const sassdoc = applySassDoc(func); - if (sassdoc) { - result.value += `\n____\n${sassdoc}`; - } - - result.value += `\n____\nFunction declared in ${sourceDocument.fileName}`; - - return result; -} - -/** - * Use the SnippetString syntax to provide smart completions of parameter names. - */ -export function mapParameterSnippet(p: ScssParameter, index: number): string { - if (p.sassdoc?.type?.length) { - const choices = parseStringLiteralChoices(p.sassdoc.type); - if (choices.length > 0) { - return `\${${index + 1}|${choices.join(",")}|}`; - } - } - - return `\${${index + 1}:${asDollarlessVariable(p.name)}}`; -} - -export function mapParameterSignature(p: ScssParameter): string { - return p.value ? `${p.name}: ${p.value}` : 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"`. - */ -export 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-server/src/features/completion/completion.ts b/packages/language-server/src/features/completion/completion.ts deleted file mode 100644 index cabcb42c..00000000 --- a/packages/language-server/src/features/completion/completion.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { - CompletionList, - MarkupKind, - InsertTextFormat, -} from "vscode-languageserver"; -import type { CompletionItem } from "vscode-languageserver"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../context-provider"; -import type { IScssDocument, ScssForward, ScssUse } from "../../parser"; -import { sassBuiltInModules } from "../sass-built-in-modules"; -import type { SassBuiltInModule } from "../sass-built-in-modules"; -import { createCompletionContext } from "./completion-context"; -import type { CompletionContext } from "./completion-context"; -import { createFunctionCompletionItems } from "./function-completion"; -import { doImportCompletion } from "./import-completion"; -import { createMixinCompletionItems } from "./mixin-completion"; -import { - createPlaceholderCompletionItems, - createPlaceholderDeclarationCompletionItems, -} from "./placeholder-completion"; -import { doSassDocCompletion } from "./sassdoc-completion"; -import { createVariableCompletionItems } from "./variable-completion"; - -export async function doCompletion( - document: TextDocument, - offset: number, -): Promise { - let completions = CompletionList.create([], false); - - const text = document.getText(); - const { storage, settings } = useContext(); - const context = createCompletionContext(document, text, offset, settings); - - if (context.sassDoc) { - return doSassDocCompletion(text, offset, context); - } - - // Drop suggestions inside `//` and `/* */` comments - if (context.comment) { - return completions; - } - - if (context.import) { - return await doImportCompletion(document, context); - } - - if (context.namespace) { - completions = doNamespacedCompletion(document, context); - } - - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - // Don't know how this would happen, but ¯\_(ツ)_/¯ - return completions; - } - - const wildcardNamespaces: ScssUse[] = []; - for (const use of scssDocument.uses.values()) { - if (use.namespace === "*" && use.link.target) { - wildcardNamespaces.push(use); - } - } - - if (wildcardNamespaces.length > 0) { - const accumulator: Map = new Map(); - - for (const use of wildcardNamespaces) { - if (!use.link.target) { - continue; - } - - const namespaceRootDocument = storage.get(use.link.target); - if (!namespaceRootDocument) { - continue; - } - - const wildcardContext = { - ...context, - namespace: "*", - }; - traverseTree( - document, - wildcardContext, - accumulator, - namespaceRootDocument, - ); - } - - completions.items = completions.items.concat( - [...accumulator.values()].flat(), - ); - } - - // If at this point we're not in a namespace context, - // but the user only wants suggestions from namespaces - // (we consider `*` a namespace), we should return an empty list. - // An exception is if the user is typing a placeholder. - // These are not prefixed with their namespace, even with @use. - if ( - settings.suggestFromUseOnly && - !context.placeholder && - !context.placeholderDeclaration - ) { - return completions; - } - - if (context.placeholderDeclaration) { - const usages = createPlaceholderDeclarationCompletionItems(); - completions.items = completions.items.concat(usages); - return completions; - } - - for (const scssDocument of storage.values()) { - if ( - !settings.suggestAllFromOpenDocument && - scssDocument.uri === document.uri - ) { - continue; - } - - if (context.placeholder) { - const placeholders = createPlaceholderCompletionItems(scssDocument); - completions.items = completions.items.concat(placeholders); - } - - if (context.variable) { - const variables = createVariableCompletionItems( - scssDocument, - document, - context, - ); - completions.items = completions.items.concat(variables); - } - - if (context.mixin) { - const mixins = createMixinCompletionItems( - scssDocument, - document, - context, - ); - completions.items = completions.items.concat(mixins); - } - - if (context.function) { - const functions = createFunctionCompletionItems( - scssDocument, - document, - context, - ); - completions.items = completions.items.concat(functions); - } - } - - return completions; -} - -function doNamespacedCompletion( - document: TextDocument, - context: CompletionContext, -): CompletionList { - const completions = CompletionList.create([], false); - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return completions; - } - - const namespace = context.namespace as string; - - let use: ScssUse | null = null; - for (const candidate of scssDocument.uses.values()) { - if ( - candidate.namespace === namespace || - candidate.namespace === `_${namespace}` - ) { - use = candidate; - break; - } - } - - if (!use || !use.link.target) { - // No match in either custom or built-in namespace, return an empty list - return completions; - } - - const namespaceRootDocument = storage.get(use.link.target); - if (!namespaceRootDocument) { - // Look for matches in built-in namespaces, which do not appear in storage - for (const [builtIn, module] of Object.entries(sassBuiltInModules)) { - if (builtIn === use.link.target) { - doBuiltInCompletion(completions, context, module); - return completions; - } - } - - // No matches, return an empty list - return completions; - } - - const accumulator: Map = new Map(); - traverseTree(document, context, accumulator, namespaceRootDocument); - - completions.items = [...accumulator.values()].flat(); - - return completions; -} - -function doBuiltInCompletion( - completions: CompletionList, - context: CompletionContext, - module: SassBuiltInModule, -): void { - completions.items = Object.entries(module.exports).map( - ([name, { description, signature, parameterSnippet, returns }]) => { - // 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 = context.originalExtension !== "scss"; - const insertText = context.word.includes(".") - ? `${isEmbedded ? "" : "."}${name}${ - signature ? `(${parameterSnippet})` : "" - }` - : name; - - return { - label: name, - filterText, - insertText, - insertTextFormat: parameterSnippet - ? InsertTextFormat.Snippet - : InsertTextFormat.PlainText, - labelDetails: { - detail: - signature && returns ? `${signature} => ${returns}` : signature, - }, - documentation: { - kind: MarkupKind.Markdown, - value: `${description}\n\n[Sass reference](${module.reference}#${name})`, - }, - }; - }, - ); -} - -function traverseTree( - document: TextDocument, - context: CompletionContext, - accumulator: Map, - leaf: IScssDocument, - hiddenSymbols: string[] = [], - shownSymbols: string[] = [], - accumulatedPrefix = "", -) { - if (accumulator.has(leaf.uri)) { - return; - } - const { storage, settings } = useContext(); - const scssDocument = storage.get(leaf.uri); - if (!scssDocument) { - return; - } - - let completionItems: CompletionItem[] = []; - - if ( - settings.suggestAllFromOpenDocument || - scssDocument.uri !== document.uri - ) { - if (context.variable) { - const variables = createVariableCompletionItems( - scssDocument, - document, - context, - hiddenSymbols, - shownSymbols, - accumulatedPrefix, - ); - completionItems = completionItems.concat(variables); - } - - if (context.mixin) { - const mixins = createMixinCompletionItems( - scssDocument, - document, - context, - hiddenSymbols, - shownSymbols, - accumulatedPrefix, - ); - completionItems = completionItems.concat(mixins); - } - - if (context.function) { - const functions = createFunctionCompletionItems( - scssDocument, - document, - context, - hiddenSymbols, - shownSymbols, - accumulatedPrefix, - ); - completionItems = completionItems.concat(functions); - } - - if (context.placeholder) { - const placeholders = createPlaceholderCompletionItems( - scssDocument, - hiddenSymbols, - shownSymbols, - ); - completionItems = completionItems.concat(placeholders); - } - - accumulator.set(leaf.uri, completionItems); - - // Check to see if we have to go deeper - // Don't follow uses, since we start with the document behind the first use, and symbols from further uses aren't available to us - // Don't follow imports, since the whole point here is to use the new module system - for (const child of leaf.getLinks({ uses: false, imports: false })) { - if (!child.link.target || child.link.target === scssDocument.uri) { - continue; - } - - const childDocument = storage.get(child.link.target); - if (!childDocument) { - continue; - } - - let hidden = hiddenSymbols; - let shown = shownSymbols; - if ( - (child as ScssForward).hide && - (child as ScssForward).hide.length > 0 - ) { - hidden = hidden.concat((child as ScssForward).hide); - } - if ( - (child as ScssForward).show && - (child as ScssForward).show.length > 0 - ) { - shown = shown.concat((child as ScssForward).show); - } - - let prefix = accumulatedPrefix; - if ((child as ScssForward).prefix) { - prefix += (child as ScssForward).prefix; - } - - traverseTree( - document, - context, - accumulator, - childDocument, - hidden, - shown, - prefix, - ); - } - } -} diff --git a/packages/language-server/src/features/completion/function-completion.ts b/packages/language-server/src/features/completion/function-completion.ts deleted file mode 100644 index 4d525d65..00000000 --- a/packages/language-server/src/features/completion/function-completion.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - CompletionItemKind, - InsertTextFormat, - CompletionItemTag, - MarkupContent, -} from "vscode-languageserver"; -import type { - CompletionItem, - CompletionItemLabelDetails, -} from "vscode-languageserver"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import type { IScssDocument, ScssFunction } from "../../parser"; -import type { CompletionContext } from "./completion-context"; -import { - makeFunctionDocumentation, - mapParameterSignature, - mapParameterSnippet, - rePrivate, -} from "./completion-utils"; - -export function createFunctionCompletionItems( - scssDocument: IScssDocument, - currentDocument: TextDocument, - context: CompletionContext, - hiddenSymbols: string[] = [], - shownSymbols: string[] = [], - prefix = "", -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (const func of scssDocument.functions.values()) { - const isPrivate = func.name.match(rePrivate); - const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; - if (isPrivate && !isFromCurrentDocument) { - // Don't suggest private functions from other files - continue; - } - - if (hiddenSymbols.includes(func.name)) { - continue; - } - - if (shownSymbols.length > 0 && !shownSymbols.includes(func.name)) { - continue; - } - - // Client needs the namespace as part of the text that is matched, - // and inserted text needs to include the `.` which will otherwise - // be replaced (except when we're embedded in Vue, Svelte or Astro). - const label = context.namespace ? `${prefix}${func.name}` : func.name; - const filterText = context.namespace - ? `${context.namespace !== "*" ? context.namespace : ""}.${prefix}${ - func.name - }` - : func.name; - const isEmbedded = context.originalExtension !== "scss"; - const insertText = context.namespace - ? context.namespace !== "*" && !isEmbedded - ? `.${prefix}${func.name}` - : `${prefix}${func.name}` - : func.name; - const sortText = isPrivate ? label.replace(/^$[_-]/, "") : undefined; - - const documentation = makeFunctionDocumentation(func, scssDocument); - - const requiredParameters = func.parameters.filter((p) => !p.value); - const parametersSnippet = requiredParameters - .map(mapParameterSnippet) - .join(", "); - const functionSignature = requiredParameters - .map(mapParameterSignature) - .join(", "); - completions.push( - makeFunctionCompletion( - label, - { - detail: `(${functionSignature})`, - }, - filterText, - sortText, - `${insertText}(${parametersSnippet})`, - func, - documentation, - ), - ); - - if (requiredParameters.length !== func.parameters.length) { - const parametersSnippet = func.parameters - .map(mapParameterSnippet) - .join(", "); - const functionSignature = func.parameters - .map(mapParameterSignature) - .join(", "); - completions.push( - makeFunctionCompletion( - label, - { - detail: `(${functionSignature})`, - }, - filterText, - sortText, - `${insertText}(${parametersSnippet})`, - func, - documentation, - ), - ); - } - } - - return completions; -} - -function makeFunctionCompletion( - label: string, - labelDetails: CompletionItemLabelDetails | undefined, - filterText: string, - sortText: string | undefined, - insertText: string, - func: ScssFunction, - documentation: MarkupContent, -): CompletionItem { - return { - label, - labelDetails, - filterText, - sortText, - kind: CompletionItemKind.Function, - insertTextFormat: InsertTextFormat.Snippet, - insertText, - tags: func.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], - documentation, - }; -} diff --git a/packages/language-server/src/features/completion/import-completion.ts b/packages/language-server/src/features/completion/import-completion.ts deleted file mode 100644 index 2466f96a..00000000 --- a/packages/language-server/src/features/completion/import-completion.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { FileType } from "vscode-css-languageservice"; -import { - type CompletionItem, - CompletionItemKind, - CompletionList, - MarkupKind, -} from "vscode-languageserver"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { URI, Utils } from "vscode-uri"; -import { useContext } from "../../context-provider"; -import { sassBuiltInModules } from "../sass-built-in-modules"; -import type { CompletionContext } from "./completion-context"; - -export const rePartialUse = /@use ["'|](?.*)["'|]?/; - -export async function doImportCompletion( - document: TextDocument, - context: CompletionContext, -): Promise { - const completions = CompletionList.create([], false); - - if (!rePartialUse.test(context.textBeforeWord)) { - return completions; - } - - const match = rePartialUse.exec(context.textBeforeWord); - if (!match) { - // Empty URL, provide suggestions for built-ins - createSassBuiltInCompletionItems(completions.items); - return completions; - } - - const [, url] = match; - const isPathImport = - url.startsWith(".") || url.includes("/") || url.includes("@"); - if (!isPathImport) { - // Don't pollute path imports with built-ins - createSassBuiltInCompletionItems(completions.items); - } - - const { workspaceRoot, fs } = useContext(); - // Need file system access for import completions - if (document.uri.startsWith("file://")) { - const moduleName = getModuleNameFromPath(url); - if (moduleName && moduleName !== "." && moduleName !== "..") { - const rootFolderUri = Utils.joinPath(workspaceRoot, "/").toString(true); - const documentFolderUri = Utils.dirname(URI.parse(document.uri)).toString( - true, - ); - const modulePath = await resolvePathToModule( - moduleName, - documentFolderUri, - rootFolderUri, - ); - if (modulePath) { - const pathWithinModule = url.substring(moduleName.length + 1); - const pathInsideModule = Utils.joinPath( - URI.parse(modulePath), - pathWithinModule, - ); - const filesInModulePath = await fs.readDirectory( - pathInsideModule.fsPath, - ); - for (const [file, fileType] of filesInModulePath) { - if (fileType === FileType.File && file.endsWith(".scss")) { - let insertText = file.slice(0, -5); - if (insertText.startsWith("_")) { - insertText = insertText.slice(1); - } - completions.items.push({ - label: escapePath(file), - insertText: escapePath(insertText), - kind: CompletionItemKind.File, - }); - } else if (fileType === FileType.Directory) { - completions.items.push({ - label: `${escapePath(file)}/`, - kind: CompletionItemKind.Folder, - insertText: `${escapePath(file)}/`, - command: { - title: "Suggest", - command: "editor.action.triggerSuggest", - }, - }); - } - } - } - } - } - - return completions; -} - -function createSassBuiltInCompletionItems(completions: CompletionItem[]): void { - for (const [moduleName, { summary, reference }] of Object.entries( - sassBuiltInModules, - )) { - completions.push({ - label: moduleName, - documentation: { - kind: MarkupKind.Markdown, - value: `${summary}\n\n[Sass reference](${reference})`, - }, - }); - } -} - -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"); -} - -async function 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 fileExists(packPath.fsPath)) { - return Utils.dirname(packPath).toString(true); - } else if ( - rootFolderUri && - documentFolderUri.startsWith(rootFolderUri) && - documentFolderUri.length !== rootFolderUri.length - ) { - return resolvePathToModule( - _moduleName, - Utils.dirname(URI.parse(documentFolderUri)).toString(true), - rootFolderUri, - ); - } - return undefined; -} - -async function fileExists(uri: string): Promise { - const { fs } = useContext(); - try { - const stat = await fs.stat(URI.parse(uri)); - if (stat.type === FileType.Unknown && stat.size === -1) { - return false; - } - - return true; - } catch (err) { - return false; - } -} diff --git a/packages/language-server/src/features/completion/index.ts b/packages/language-server/src/features/completion/index.ts deleted file mode 100644 index d6e48f9d..00000000 --- a/packages/language-server/src/features/completion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./completion"; diff --git a/packages/language-server/src/features/completion/mixin-completion.ts b/packages/language-server/src/features/completion/mixin-completion.ts deleted file mode 100644 index 3b4b599c..00000000 --- a/packages/language-server/src/features/completion/mixin-completion.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - CompletionItemKind, - CompletionItemTag, - InsertTextFormat, - MarkupContent, -} from "vscode-languageserver"; -import type { - CompletionItem, - CompletionItemLabelDetails, -} from "vscode-languageserver"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../context-provider"; -import type { IScssDocument, ScssMixin } from "../../parser"; -import type { CompletionContext } from "./completion-context"; -import { - makeMixinDocumentation, - mapParameterSignature, - mapParameterSnippet, - rePrivate, -} from "./completion-utils"; - -export function createMixinCompletionItems( - scssDocument: IScssDocument, - currentDocument: TextDocument, - context: CompletionContext, - hiddenSymbols: string[] = [], - shownSymbols: string[] = [], - prefix = "", -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (const mixin of scssDocument.mixins.values()) { - const isPrivate = mixin.name.match(rePrivate); - const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; - if (isPrivate && !isFromCurrentDocument) { - // Don't suggest private mixins from other files - continue; - } - - if (hiddenSymbols.includes(mixin.name)) { - continue; - } - - if (shownSymbols.length > 0 && !shownSymbols.includes(mixin.name)) { - continue; - } - - const documentation = makeMixinDocumentation(mixin, scssDocument); - - // Client needs the namespace as part of the text that is matched, - // and inserted text needs to include the `.` which will otherwise - // be replaced (except when we're embedded in Vue, Svelte or Astro). - const label = context.namespace ? `${prefix}${mixin.name}` : mixin.name; - const filterText = context.namespace - ? context.namespace !== "*" - ? `${context.namespace}.${prefix}${mixin.name}` - : `${prefix}${mixin.name}` - : mixin.name; - - const isEmbedded = context.originalExtension !== "scss"; - const insertText = context.namespace - ? context.namespace !== "*" && !isEmbedded - ? `.${prefix}${mixin.name}` - : `${prefix}${mixin.name}` - : mixin.name; - const sortText = isPrivate ? label.replace(/^$[_-]/, "") : undefined; - - // Use the SnippetString syntax to provide smart completions of parameter names - const labelDetails: CompletionItemLabelDetails | undefined = undefined; - - const requiredParameters = mixin.parameters.filter((p) => !p.value); - if (requiredParameters.length === 0) { - makeMixinCompletion( - completions, - label, - labelDetails, - filterText, - sortText, - insertText, - mixin, - documentation, - ); - } - - if (requiredParameters.length > 0) { - const parametersSnippet = requiredParameters - .map(mapParameterSnippet) - .join(", "); - const functionSignature = requiredParameters - .map(mapParameterSignature) - .join(", "); - makeMixinCompletion( - completions, - label, - { - detail: `(${functionSignature})`, - }, - filterText, - sortText, - `${insertText}(${parametersSnippet})`, - mixin, - documentation, - ); - } - - if (mixin.parameters.length !== requiredParameters.length) { - const parametersSnippet = mixin.parameters - .map(mapParameterSnippet) - .join(", "); - const functionSignature = mixin.parameters - .map(mapParameterSignature) - .join(", "); - makeMixinCompletion( - completions, - label, - { - detail: `(${functionSignature})`, - }, - filterText, - sortText, - `${insertText}(${parametersSnippet})`, - mixin, - documentation, - ); - } - } - - return completions; -} - -function makeMixinCompletion( - completions: CompletionItem[], - label: string, - labelDetails: CompletionItemLabelDetails | undefined, - filterText: string, - sortText: string | undefined, - insertText: string, - mixin: ScssMixin, - documentation: MarkupContent, -): void { - const context = useContext(); - - if (context?.settings?.suggestionStyle !== "bracket") { - completions.push({ - label, - labelDetails, - filterText, - sortText, - kind: CompletionItemKind.Snippet, - insertTextFormat: InsertTextFormat.Snippet, - insertText, - tags: mixin.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], - documentation, - }); - } - - // 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 ( - mixin.sassdoc?.content && - context?.settings?.suggestionStyle !== "nobracket" - ) { - const details = { ...labelDetails }; - details.detail = details.detail ? `${details.detail} { }` : " { }"; - completions.push({ - label, - labelDetails: details, - filterText, - sortText, - kind: CompletionItemKind.Snippet, - insertTextFormat: InsertTextFormat.Snippet, - insertText: (insertText += " {\n\t$0\n}"), - tags: mixin.sassdoc.deprecated ? [CompletionItemTag.Deprecated] : [], - documentation, - }); - } -} diff --git a/packages/language-server/src/features/completion/placeholder-completion.ts b/packages/language-server/src/features/completion/placeholder-completion.ts deleted file mode 100644 index 45089d07..00000000 --- a/packages/language-server/src/features/completion/placeholder-completion.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - type CompletionItem, - CompletionItemKind, - CompletionItemTag, - InsertTextFormat, -} from "vscode-languageserver"; -import { useContext } from "../../context-provider"; -import { type IScssDocument } from "../../parser"; -import { applySassDoc } from "../../utils/sassdoc"; - -export function createPlaceholderCompletionItems( - scssDocument: IScssDocument, - hiddenSymbols: string[] = [], - shownSymbols: string[] = [], -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (const placeholder of scssDocument.placeholders.values()) { - if (hiddenSymbols.includes(placeholder.name)) { - continue; - } - - if (shownSymbols.length > 0 && !shownSymbols.includes(placeholder.name)) { - continue; - } - - const label = placeholder.name; - const filterText = placeholder.name.substring(1); - - let documentation = placeholder.name; - const sassdoc = applySassDoc(placeholder); - if (sassdoc) { - documentation += `\n____\n${sassdoc}`; - } - - const detail = `Placeholder declared in ${scssDocument.fileName}`; - - completions.push({ - label, - kind: CompletionItemKind.Class, - detail, - filterText, - insertText: filterText, - insertTextFormat: InsertTextFormat.Snippet, - tags: placeholder.sassdoc?.deprecated - ? [CompletionItemTag.Deprecated] - : undefined, - documentation: { - kind: "markdown", - value: documentation, - }, - }); - } - - return completions; -} - -export function createPlaceholderDeclarationCompletionItems(): CompletionItem[] { - const uniquePlaceholders = new Map< - string, - [CompletionItem | undefined, CompletionItem | undefined] - >(); - - const { storage, settings } = useContext(); - for (const document of storage.values()) { - for (const usage of document.placeholderUsages.values()) { - const label = usage.name; - if (uniquePlaceholders.has(label)) { - continue; - } - - const filterText = usage.name.substring(1); - uniquePlaceholders.set(label, [ - settings.suggestionStyle !== "bracket" - ? { - label, - kind: CompletionItemKind.Class, - filterText, - insertText: filterText, - insertTextFormat: InsertTextFormat.Snippet, - } - : undefined, - settings.suggestionStyle !== "nobracket" - ? { - label, - labelDetails: { detail: " { }" }, - kind: CompletionItemKind.Class, - filterText, - insertText: filterText + " {\n\t$0\n}", - insertTextFormat: InsertTextFormat.Snippet, - } - : undefined, - ]); - } - } - - return [...uniquePlaceholders.values()] - .flat() - .filter((p) => typeof p !== "undefined") as CompletionItem[]; -} diff --git a/packages/language-server/src/features/completion/sassdoc-completion.ts b/packages/language-server/src/features/completion/sassdoc-completion.ts deleted file mode 100644 index d6bd0b84..00000000 --- a/packages/language-server/src/features/completion/sassdoc-completion.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { tokenizer } from "scss-symbols-parser"; -import { - CompletionItemKind, - CompletionList, - InsertTextFormat, -} from "vscode-languageserver-types"; -import { getLinesFromText } from "../../utils/string"; -import { sassDocAnnotations } from "../sassdoc-annotations"; -import type { CompletionContext } from "./completion-context"; - -export function doSassDocCompletion( - text: string, - offset: number, - context: CompletionContext, -): CompletionList { - if (!context.word) { - const textAfterCursor = text.slice(Math.max(0, offset)); - const isCursorAboveFunction = /^(?:\r\n|\r|\n)\s*@function/.exec( - textAfterCursor, - ); - const isCursorAboveMixin = /^(?:\r\n|\r|\n)\s*@mixin/.exec(textAfterCursor); - - if (isCursorAboveFunction || isCursorAboveMixin) { - const textBeforeCursor = text.slice(0, Math.max(0, offset)); - const linesBeforeCursor = getLinesFromText(textBeforeCursor); - - const isCursorBelowSassDocLine = - linesBeforeCursor[linesBeforeCursor.length - 2]?.startsWith("///"); - if (!isCursorBelowSassDocLine) { - return doSassDocParameterCompletion( - textAfterCursor, - isCursorAboveFunction ? "function" : "mixin", - ); - } - } - } - - return doSassDocAnnotationCompletion(context); -} - -function doSassDocAnnotationCompletion({ - textBeforeWord, -}: CompletionContext): CompletionList { - const completions = CompletionList.create([], true); - - if (textBeforeWord.includes("@example ")) { - completions.items.push({ - label: "scss", - sortText: "-", - kind: CompletionItemKind.Value, - }); - completions.items.push({ - label: "css", - kind: CompletionItemKind.Value, - }); - completions.items.push({ - label: "markup", - kind: CompletionItemKind.Value, - }); - completions.items.push({ - label: "javascript", - sortText: "y", - kind: CompletionItemKind.Value, - }); - return completions; - } - - for (const { - annotation, - aliases, - insertText, - insertTextFormat, - } of sassDocAnnotations) { - const item = { - label: annotation, - kind: CompletionItemKind.Keyword, - insertText, - insertTextFormat, - sortText: "-", // Push ourselves to the head of the list - }; - - completions.items.push(item); - - if (aliases) { - for (const alias of aliases) { - completions.items.push({ - ...item, - label: alias, - insertText: insertText - ? insertText.replace(annotation, alias) - : insertText, - }); - } - } - } - - return completions; -} - -function doSassDocParameterCompletion( - textAfterCursor: string, - context: "function" | "mixin", -): CompletionList { - const completions = CompletionList.create([], true); - const tokens = tokenizer(textAfterCursor); - - const isCursorAboveMixinWithParameters = - /^(?:\r\n|\r|\n)\s*@mixin .+\(.+\)/.exec(textAfterCursor); - if (context === "mixin" && !isCursorAboveMixinWithParameters) { - // If this is a mixin without parameters we don't have many options for clever suggestions. - // Look for a @content, but otherwise just suggest an @output and a description. - - let hasContentDirective = false; - let bracketCount = 0; - const openBracket = tokens.findIndex((t) => t[0] === "{"); - for (let i = openBracket; i < tokens.length; i++) { - const token = tokens[i]; - if (token[0] === "{") { - bracketCount++; - } else if (token[0] === "}") { - bracketCount--; - if (bracketCount === 0) { - break; - } - } - - if (token[1] === "@content") { - hasContentDirective = true; - break; - } - } - - const contentSnippet = hasContentDirective ? "\n/// @content ${1}" : ""; - const snippet = ` \${0}${contentSnippet}\n/// @output \${2}`; - - completions.items.push({ - label: "SassDoc block", - insertText: snippet, - insertTextFormat: InsertTextFormat.Snippet, - sortText: "-", - }); - - return completions; - } - - const [, text] = tokens.find((t) => t[0] === "brackets") as [ - "brackets", - string, - number, - ]; - - const ppl = 2; // Number of placeholders in the /// @param snippet below - - const parameters = text.replace(/[()]/g, "").split(","); - const parameterSnippet: string = parameters - .map((p, i) => { - const [parameterName, defaultValue] = p - .split(":") - .map((nd) => nd.trim()) as [string, string | undefined]; - - let typeSnippet = "type"; - let defaultValueSnippet = ""; - if (defaultValue) { - defaultValueSnippet = ` [${defaultValue}]`; - - // Try to give a sensible default type if we can - try { - 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"; - } - } - } catch { - // Oops! Carry on with a generic suggestion. - } - } - - return `/// @param {\${${ - i * ppl + 1 - }:${typeSnippet}}} \\${parameterName}${defaultValueSnippet} \${${ - i * ppl + 2 - }:-}`; - }) - .join("\n"); - - const isFunc = context === "function"; - const returnSnippet = `\n/// @${ - isFunc ? `return {\${${ppl * parameters.length + 1}:type}}` : "output" - } \${${ppl * parameters.length + 2}:-}`; - const snippet = ` \${0}\n${parameterSnippet}${returnSnippet}`; - - completions.items.push({ - label: "SassDoc block", - insertText: snippet, - insertTextFormat: InsertTextFormat.Snippet, - sortText: "-", - }); - - return completions; -} diff --git a/packages/language-server/src/features/completion/variable-completion.ts b/packages/language-server/src/features/completion/variable-completion.ts deleted file mode 100644 index 0c1f779a..00000000 --- a/packages/language-server/src/features/completion/variable-completion.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { - CompletionItemKind, - CompletionItemTag, - MarkupKind, -} from "vscode-languageserver-types"; -import type { CompletionItem } from "vscode-languageserver-types"; -import { IScssDocument } from "../../parser"; -import { applySassDoc } from "../../utils/sassdoc"; -import { getBaseValueFrom, isReferencingVariable } from "../../utils/scss"; -import { asDollarlessVariable } from "../../utils/string"; -import { isColor } from "./color-completion"; -import type { CompletionContext } from "./completion-context"; -import { rePrivate } from "./completion-utils"; - -export function createVariableCompletionItems( - scssDocument: IScssDocument, - currentDocument: TextDocument, - context: CompletionContext, - hiddenSymbols: string[] = [], - shownSymbols: string[] = [], - prefix = "", -): CompletionItem[] { - const completions: CompletionItem[] = []; - - for (const variable of scssDocument.variables.values()) { - let value = variable.value; - if (isReferencingVariable(variable)) { - value = getBaseValueFrom(variable, scssDocument).value; - } - - const color = value ? isColor(value) : null; - const completionKind = color - ? CompletionItemKind.Color - : CompletionItemKind.Variable; - - let documentation = - color || - [ - "```scss", - `${variable.name}: ${value};${ - value !== variable.value ? ` // via ${variable.value}` : "" - }`, - "```", - ].join("\n") || - ""; - let detail = undefined; - - let label = variable.name; - let sortText; - let filterText; - let insertText; - - if (variable.mixin) { - // Add 'argument from MIXIN_NAME' suffix if Variable is Mixin argument - detail = `Argument from ${variable.mixin}`; - } else { - const isPrivate = variable.name.match(rePrivate); - const isFromCurrentDocument = scssDocument.uri === currentDocument.uri; - - if (isPrivate && !isFromCurrentDocument) { - continue; - } - - if (hiddenSymbols.includes(variable.name)) { - continue; - } - - if (shownSymbols.length > 0 && !shownSymbols.includes(variable.name)) { - continue; - } - - if (isPrivate) { - sortText = label.replace(/^$[_-]/, ""); - } - - const sassdoc = applySassDoc(variable); - if (sassdoc) { - documentation += `\n____\n${sassdoc}`; - } - } - - documentation += `\n____\nVariable declared in ${scssDocument.fileName}`; - - const isEmbedded = context.originalExtension !== "scss"; - if (context.namespace) { - // Avoid ending up with namespace.prefix-$variable - label = `$${prefix}${asDollarlessVariable(variable.name)}`; - // The `.` in the namespace gets replaced unless we have a $ character after it. - // Except when we're embedded in Vue, Svelte or Astro, where the . is not replaced. - // Also, in embedded scenarios where we don't use a namespace, the existing $ sign is not replaced. - insertText = context.word.endsWith(".") - ? `${isEmbedded ? "" : "."}${label}` - : isEmbedded - ? asDollarlessVariable(label) - : label; - filterText = context.word.endsWith(".") - ? `${context.namespace}.${label}` - : label; - } else if ( - context.originalExtension === "vue" || - context.originalExtension === "astro" - ) { - // In Vue and Astro files, the $ does not get replaced by the suggestion, - // so exclude it from the insertText. - insertText = asDollarlessVariable(label); - } - - completions.push({ - label, - filterText, - insertText, - sortText, - commitCharacters: [";", ","], - kind: completionKind, - detail, - tags: variable.sassdoc?.deprecated ? [CompletionItemTag.Deprecated] : [], - documentation: - completionKind === CompletionItemKind.Color - ? documentation - : { - kind: MarkupKind.Markdown, - value: documentation, - }, - }); - } - - return completions; -} diff --git a/packages/language-server/src/features/decorators/color-decorators.ts b/packages/language-server/src/features/decorators/color-decorators.ts deleted file mode 100644 index 10cea4f6..00000000 --- a/packages/language-server/src/features/decorators/color-decorators.ts +++ /dev/null @@ -1,97 +0,0 @@ -import ColorDotJS from "colorjs.io"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { ColorInformation, SymbolKind } from "vscode-languageserver-types"; -import { useContext } from "../../context-provider"; -import { NodeType, ScssVariable } from "../../parser"; -import { getBaseValueFrom, isReferencingVariable } from "../../utils/scss"; -import { isColor } from "../completion/color-completion"; -import { getDefinitionSymbol } from "../go-definition"; - -export function findDocumentColors(document: TextDocument): ColorInformation[] { - const colorInformation: ColorInformation[] = []; - - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return colorInformation; - } - - scssDocument.ast.accept((node) => { - if (node.type !== NodeType.VariableName) { - // continue - return true; - } - - const parent = node.getParent(); - if ( - parent.type !== NodeType.VariableDeclaration && - parent.type !== NodeType.FunctionParameter - ) { - const identifier = { - name: node.getName(), - position: document.positionAt(node.offset), - kind: SymbolKind.Variable, - }; - - const [symbol, sourceDocument] = getDefinitionSymbol( - document, - identifier, - ); - // Symbol is null if current node _is_ the definition. In that case, we - // defer color decoration to the VS Code language server. - if (!symbol) { - // continue - return true; - } - - const variable = symbol as ScssVariable; - - let value = variable.value; - if (!value) { - // continue - return true; - } - - if (isReferencingVariable(variable)) { - value = getBaseValueFrom(variable, sourceDocument).value; - } - - if (!value || !isColor(value)) { - // continue - return true; - } - - const srgbaColor = ColorDotJS.to(ColorDotJS.parse(value), "srgb"); - const color: ColorInformation = { - color: { - alpha: srgbaColor.alpha || 1, - red: srgbaColor.coords[0], - green: srgbaColor.coords[1], - blue: srgbaColor.coords[2], - }, - range: { - start: document.positionAt(node.offset), - end: document.positionAt(node.offset + node.getName().length), - }, - }; - colorInformation.push(color); - } - - return true; - }); - - return colorInformation; -} - -// Maybe we just don't provide any options? -// Clicking to see the other non-rgb options replaces the variable reference. -// The below works, but is less helpful than I'd like. Keep for reference, in case of user feedback. -// export function getColorPresentations( -// document: TextDocument, -// color: Color, -// range: Range, -// ): ColorPresentation[] { -// const ls = getLanguageService(); -// const stylesheet = ls.parseStylesheet(document); -// return ls.getColorPresentations(document, stylesheet, color, range); -// } diff --git a/packages/language-server/src/features/diagnostics/diagnostics.ts b/packages/language-server/src/features/diagnostics/diagnostics.ts deleted file mode 100644 index 0c76ce0d..00000000 --- a/packages/language-server/src/features/diagnostics/diagnostics.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - DiagnosticSeverity, - DiagnosticTag, - SymbolKind, -} from "vscode-languageserver-types"; -import type { - Diagnostic, - VersionedTextDocumentIdentifier, -} from "vscode-languageserver-types"; -import { EXTENSION_NAME } from "../../constants"; -import { useContext } from "../../context-provider"; -import { NodeType } from "../../parser"; -import type { - INode, - IScssDocument, - ScssForward, - ScssSymbol, -} from "../../parser"; -import { asDollarlessVariable } from "../../utils/string"; - -export async function doDiagnostics( - document: VersionedTextDocumentIdentifier, -): Promise { - const diagnostics: Diagnostic[] = []; - const { storage } = useContext(); - const openDocument = storage.get(document.uri); - if (!openDocument) { - console.error( - "Tried to do diagnostics on a document that has not been scanned. This should never happen.", - ); - return diagnostics; - } - - const references: INode[] = getReferences(openDocument.ast); - if (references.length === 0) { - return diagnostics; - } - - // Do traversal once, and then do diagnostics on the symbols for each reference - const symbols: ScssSymbol[] = []; - doSymbolHunting(openDocument, symbols); - - for (const node of references) { - for (const symbol of symbols) { - let nodeKind: SymbolKind | null = null; - - switch (node.type) { - case NodeType.Function: - nodeKind = SymbolKind.Function; - break; - case NodeType.MixinReference: - nodeKind = SymbolKind.Method; - break; - case NodeType.VariableName: - nodeKind = SymbolKind.Variable; - break; - case NodeType.SimpleSelector: - nodeKind = SymbolKind.Class; - break; - } - - if (nodeKind === null) { - continue; - } - - const name = - nodeKind === SymbolKind.Class ? node.getText() : node.getName(); - if (symbol.kind === nodeKind && name === symbol.name) { - const diagnostic = createDiagnostic(openDocument, node, symbol); - if (diagnostic) { - diagnostics.push(diagnostic); - } - } - } - } - - return diagnostics; -} - -function getReferences(fromNode: INode): INode[] { - return fromNode.getChildren().flatMap((child) => { - if (child.type === NodeType.VariableName) { - const parent = child.getParent(); - if ( - parent.type !== NodeType.FunctionParameter && - parent.type !== NodeType.VariableDeclaration - ) { - return [child]; - } - } else if (child.type === NodeType.Identifier) { - let i = 0; - let node = child; - while ( - node.type !== NodeType.MixinReference && - node.type !== NodeType.Function && - i !== 2 - ) { - node = node.getParent(); - i++; - } - - if ( - node.type === NodeType.MixinReference || - node.type === NodeType.Function - ) { - return [node]; - } - } else if (child.type === NodeType.SimpleSelector) { - let node = child; - let i = 0; - while (node.type !== NodeType.ExtendsReference && i !== 2) { - node = node.getParent(); - i++; - } - if (node.type === NodeType.ExtendsReference) { - return [child]; - } - } - - return getReferences(child); - }); -} - -function doSymbolHunting( - openDocument: IScssDocument, - result: ScssSymbol[], -): ScssSymbol[] { - traverseTree(openDocument, openDocument, result); - return result; -} - -function traverseTree( - openDocument: IScssDocument, - childDocument: IScssDocument, - result: ScssSymbol[], - accumulatedPrefix = "", - depth = 0, -): ScssSymbol[] { - const { storage } = useContext(); - const scssDocument = storage.get(childDocument.uri); - if (!scssDocument) { - return result; - } - - for (const symbol of scssDocument.getSymbols()) { - // Placeholders are not namespaced the same way other symbols are - if (symbol.kind === SymbolKind.Class) { - result.push({ - ...symbol, - name: symbol.name, - }); - continue; - } - - // The symbol may have a prefix in the open document, so we need to add it here - // so we can compare apples to apples later on. - let symbolName = `${accumulatedPrefix}${asDollarlessVariable(symbol.name)}`; - if (symbol.kind === SymbolKind.Variable) { - symbolName = `$${symbolName}`; - } - - result.push({ - ...symbol, - name: symbolName, - }); - } - - // Check to see if we have to go deeper - // Don't follow uses beyond the first, since symbols from those aren't available to us anyway - // Don't follow imports, since the whole point here is to use the new module system - for (const child of scssDocument.getLinks({ - uses: depth === 0, - imports: false, - })) { - if (!child.link.target || child.link.target === scssDocument.uri) { - continue; - } - - const childDocument = storage.get(child.link.target); - if (!childDocument) { - continue; - } - - let prefix = accumulatedPrefix; - if ((child as ScssForward).prefix) { - prefix += (child as ScssForward).prefix; - } - - traverseTree(openDocument, childDocument, result, prefix, depth + 1); - } - - return result; -} - -function createDiagnostic( - openDocument: IScssDocument, - node: INode, - symbol: ScssSymbol, -): Diagnostic | null { - if (typeof symbol.sassdoc?.deprecated !== "undefined") { - return { - message: symbol.sassdoc.deprecated || `${symbol.name} is deprecated`, - range: openDocument.getNodeRange(node), - source: EXTENSION_NAME, - tags: [DiagnosticTag.Deprecated], - severity: DiagnosticSeverity.Hint, - }; - } - - return null; -} diff --git a/packages/language-server/src/features/diagnostics/index.ts b/packages/language-server/src/features/diagnostics/index.ts deleted file mode 100644 index 0f967b0e..00000000 --- a/packages/language-server/src/features/diagnostics/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./diagnostics"; diff --git a/packages/language-server/src/features/go-definition/go-definition.ts b/packages/language-server/src/features/go-definition/go-definition.ts deleted file mode 100644 index be46a035..00000000 --- a/packages/language-server/src/features/go-definition/go-definition.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Location, SymbolKind } from "vscode-languageserver"; -import type { - TextDocument, - Position, -} from "vscode-languageserver-textdocument"; -import { useContext } from "../../context-provider"; -import { NodeType } from "../../parser"; -import type { - INode, - IScssDocument, - ScssForward, - ScssSymbol, -} from "../../parser"; -import type StorageService from "../../storage"; -import { asDollarlessVariable } from "../../utils/string"; - -interface Identifier { - kind: SymbolKind; - position: Position; - name: string; -} - -function samePosition(a: Position | undefined, b: Position): boolean { - if (a === undefined) { - return false; - } - - return a.line === b.line && a.character === b.character; -} - -export function goDefinition( - document: TextDocument, - offset: number, -): Location | null { - const result = getDefinition(document, offset); - if (!result) { - return null; - } - - const [definition, sourceDocument] = result; - if (!definition || !sourceDocument) { - return null; - } - - const symbol = Location.create(sourceDocument.uri, { - start: definition.position, - end: { - line: definition.position.line, - character: definition.position.character + definition.name.length, - }, - }); - - return symbol; -} - -export function getDefinition( - document: TextDocument, - offset: number, -): [ScssSymbol, IScssDocument] | null { - const { storage } = useContext(); - const currentScssDocument = storage.get(document.uri); - if (!currentScssDocument) { - return null; - } - - const hoverNode = currentScssDocument.getNodeAt(offset); - if (!hoverNode || !hoverNode.type) { - return null; - } - - const identifier: Identifier | null = getIdentifier(document, hoverNode); - if (!identifier) { - return null; - } - - const [definition, sourceDocument] = getDefinitionSymbol( - document, - identifier, - ); - - if (!definition || !sourceDocument) { - return null; - } - - return [definition, sourceDocument]; -} - -function getIdentifier( - document: TextDocument, - hoverNode: INode, -): Identifier | null { - if (hoverNode.type === NodeType.VariableName) { - const parent = hoverNode.getParent(); - - const isFunctionParameter = parent.type === NodeType.FunctionParameter; - const isDeclaration = parent.type === NodeType.VariableDeclaration; - - if (!isFunctionParameter && !isDeclaration) { - return { - name: hoverNode.getName(), - position: document.positionAt(hoverNode.offset), - kind: SymbolKind.Variable, - }; - } - } else if (hoverNode.type === NodeType.Identifier) { - if (hoverNode.getParent()?.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 as - // both a function and a mixin. - - const candidateIdentifier: Identifier = { - name: hoverNode.getText(), - position: document.positionAt(hoverNode.offset), - kind: SymbolKind.Method, - }; - - const [asMixin] = getDefinitionSymbol(document, candidateIdentifier); - - if (asMixin) { - return candidateIdentifier; - } - - candidateIdentifier.kind = SymbolKind.Function; - - const [asFunction] = getDefinitionSymbol(document, candidateIdentifier); - - if (asFunction) { - return candidateIdentifier; - } - - return null; - } - - let i = 0; - let node = hoverNode; - let isMixin = false; - let isFunction = false; - - while (!isMixin && !isFunction && i !== 2) { - node = node.getParent(); - - isMixin = node.type === NodeType.MixinReference; - isFunction = node.type === NodeType.Function; - - i++; - } - - if (node && (isMixin || isFunction)) { - let kind: SymbolKind = SymbolKind.Method; - - if (isFunction) { - kind = SymbolKind.Function; - } - - return { - name: node.getName(), - position: document.positionAt(node.offset), - kind, - }; - } - } else if (hoverNode.type === NodeType.SelectorPlaceholder) { - return { - name: hoverNode.getText(), - position: document.positionAt(hoverNode.offset), - kind: SymbolKind.Class, - }; - } - - return null; -} - -export function getDefinitionSymbol( - document: TextDocument, - identifier: Identifier, -): [null, null] | [ScssSymbol, IScssDocument] { - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return [null, null]; - } - - for (const symbol of scssDocument.getSymbols()) { - const symbolName = asDollarlessVariable(symbol.name); - const identifierName = asDollarlessVariable(identifier.name); - if (symbolName === identifierName && symbol.kind === identifier.kind) { - return [symbol, scssDocument]; - } - } - - // Don't follow forwards from the current document, since the current doc doesn't have access to its symbols - for (const { link } of scssDocument.getLinks({ forwards: false })) { - const scssDocument = storage.get(link.target as string); - if (!scssDocument) { - continue; - } - - const [symbol, sourceDocument] = traverseTree( - scssDocument, - identifier, - storage, - ); - if (symbol) { - return [symbol, sourceDocument]; - } - } - - // Fall back to the old way of doing things if we can't find the symbol via `@use` - for (const scssDocument of storage.values()) { - let symbols: IterableIterator; - - if (identifier.kind === SymbolKind.Variable) { - symbols = scssDocument.variables.values(); - } else if (identifier.kind === SymbolKind.Class) { - symbols = scssDocument.placeholders.values(); - } else if (identifier.kind === SymbolKind.Function) { - symbols = scssDocument.functions.values(); - } else { - symbols = scssDocument.mixins.values(); - } - - for (const symbol of symbols) { - if ( - symbol.name === identifier.name && - !samePosition(symbol.position, identifier.position) - ) { - return [symbol, scssDocument]; - } - } - } - - return [null, null]; -} - -function traverseTree( - document: IScssDocument, - identifier: Identifier, - storage: StorageService, - accumulatedPrefix = "", -): [null, null] | [ScssSymbol, IScssDocument] { - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return [null, null]; - } - - for (const symbol of scssDocument.getSymbols()) { - if (symbol.kind === SymbolKind.Class) { - // Placeholders are not namespaced the same way other symbols are - if (symbol.name === identifier.name && symbol.kind === identifier.kind) { - return [symbol, scssDocument]; - } - continue; - } - - const symbolName = `${accumulatedPrefix}${asDollarlessVariable( - symbol.name, - )}`; - const identifierName = asDollarlessVariable(identifier.name); - if (symbolName === identifierName && symbol.kind === identifier.kind) { - return [symbol, scssDocument]; - } - } - - // Check to see if we have to go deeper - // Don't follow uses, since we start with the document behind the first use, and symbols from further uses aren't available to us - // Don't follow imports, since the whole point here is to use the new module system - for (const child of scssDocument.getLinks({ - uses: false, - imports: false, - })) { - if (!child.link.target || child.link.target === scssDocument.uri) { - continue; - } - - const childDocument = storage.get(child.link.target); - if (!childDocument) { - continue; - } - - let prefix = accumulatedPrefix; - if ((child as ScssForward).prefix) { - prefix += (child as ScssForward).prefix; - } - - const [symbol, document] = traverseTree( - childDocument, - identifier, - storage, - prefix, - ); - if (symbol) { - return [symbol, document]; - } - } - - return [null, null]; -} diff --git a/packages/language-server/src/features/go-definition/index.ts b/packages/language-server/src/features/go-definition/index.ts deleted file mode 100644 index 075f8e82..00000000 --- a/packages/language-server/src/features/go-definition/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./go-definition"; diff --git a/packages/language-server/src/features/hover/hover.ts b/packages/language-server/src/features/hover/hover.ts deleted file mode 100644 index 69983eb8..00000000 --- a/packages/language-server/src/features/hover/hover.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { MarkupKind, SymbolKind } from "vscode-languageserver"; -import type { Hover, MarkupContent } from "vscode-languageserver"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../context-provider"; -import { - NodeType, - IScssDocument, - ScssSymbol, - ScssVariable, - ScssMixin, - ScssFunction, - ScssForward, - tokenizer, - Token, - ScssPlaceholder, -} from "../../parser"; -import { applySassDoc } from "../../utils/sassdoc"; -import { getBaseValueFrom, isReferencingVariable } from "../../utils/scss"; -import { asDollarlessVariable } from "../../utils/string"; -import { - makeFunctionDocumentation, - makeMixinDocumentation, -} from "../completion/completion-utils"; -import { sassBuiltInModules } from "../sass-built-in-modules"; -import { sassDocAnnotations } from "../sassdoc-annotations"; - -interface Identifier { - kind: SymbolKind; - name: string; -} - -function formatVariableMarkupContent( - variable: ScssVariable, - sourceDocument: IScssDocument, -): MarkupContent { - let value = variable.value; - if (isReferencingVariable(variable)) { - value = getBaseValueFrom(variable, sourceDocument).value; - } - - value = value || ""; - - const result = { - kind: MarkupKind.Markdown, - value: [ - "```scss", - `${variable.name}: ${value};${ - value !== variable.value ? ` // via ${variable.value}` : "" - }`, - "```", - ].join("\n"), - }; - - const sassdoc = applySassDoc(variable); - if (sassdoc) { - result.value += `\n____\n${sassdoc}`; - } - - result.value += `\n____\nVariable declared in ${sourceDocument.fileName}`; - - return result; -} - -function formatPlaceholderMarkupContent( - placeholder: ScssPlaceholder, - sourceDocument: IScssDocument, -): MarkupContent { - const result = { - kind: MarkupKind.Markdown, - value: ["```scss", placeholder.name, "```"].join("\n"), - }; - - const sassdoc = applySassDoc(placeholder); - if (sassdoc) { - result.value += `\n____\n${sassdoc}`; - } - - result.value += `\n____\nPlaceholder declared in ${sourceDocument.fileName}`; - - return result; -} - -export function doHover(document: TextDocument, offset: number): Hover | null { - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return null; - } - - const hoverNode = scssDocument.getNodeAt(offset); - if (!hoverNode || !hoverNode.type) { - return null; - } - - let identifier: Identifier | null = null; - switch (hoverNode.type) { - case NodeType.VariableName: { - const parent = hoverNode.getParent(); - - if ( - parent.type !== NodeType.VariableDeclaration && - parent.type !== NodeType.FunctionParameter - ) { - identifier = { - name: hoverNode.getName(), - kind: SymbolKind.Variable, - }; - } - - break; - } - - case NodeType.Identifier: { - let node; - let type: SymbolKind | null = null; - - const parent = hoverNode.getParent(); - if (parent.type === NodeType.Function) { - node = parent; - type = SymbolKind.Function; - } else if (parent.type === NodeType.MixinReference) { - node = parent; - type = SymbolKind.Method; - } - - if (type === null) { - return null; - } - - if (node) { - identifier = { - name: node.getName(), - kind: type, - }; - } - - break; - } - - case NodeType.MixinReference: { - identifier = { - name: hoverNode.getName(), - kind: SymbolKind.Method, - }; - - break; - } - - case NodeType.SelectorPlaceholder: { - identifier = { - name: hoverNode.getText(), - kind: SymbolKind.Class, - }; - break; - } - - case NodeType.Stylesheet: { - // Hover information for SassDoc. - // SassDoc is considered a comment, which doesn't have its own NodeType. - // Tokenize the document and look for the closest non-space token to offset. - // If it's a comment, look for SassDoc annotations under the cursor. - - const tokens = tokenizer(document.getText()); - - let hoverToken: Token | null = null; - for (const token of tokens) { - const [type, text, tokenOffset] = token; - if (typeof tokenOffset !== "number") { - continue; - } - - if (tokenOffset > offset) { - break; - } - - hoverToken = [type, text, tokenOffset]; - } - - if (hoverToken && hoverToken[0] === "comment") { - const commentText = hoverToken[1]; - const candidate = sassDocAnnotations.find(({ annotation, aliases }) => { - return ( - commentText.includes(annotation) || - aliases?.some((alias) => commentText.includes(alias)) - ); - }); - - if (candidate) { - const annotationPosition = commentText.indexOf(candidate.annotation); - const annotationOffset = (hoverToken[2] || 0) + annotationPosition; - if (offset < annotationOffset) { - // If offset is under the result of the above, we're hovering right before the annotation. - return null; - } - - const annotationEnd = - annotationOffset + candidate.annotation.length - 1; - if (annotationEnd < offset) { - // If offset is over the result of the above, we're hovering past the token. - return null; - } - - return { - contents: { - kind: MarkupKind.Markdown, - value: [ - candidate.annotation, - "____", - `[SassDoc reference](http://sassdoc.com/annotations/#${candidate.annotation.slice( - 1, - )})`, - ].join("\n"), - }, - }; - } - } - - break; - } - // No default - } - - if (!identifier) { - return null; - } - - const [symbol, sourceDocument] = doSymbolHunting(document, identifier); - - // Content for Hover popup - let contents: MarkupContent | undefined; - if (symbol && sourceDocument) { - switch (identifier.kind) { - case SymbolKind.Variable: { - contents = formatVariableMarkupContent( - symbol as ScssVariable, - sourceDocument, - ); - break; - } - - case SymbolKind.Method: { - contents = makeMixinDocumentation(symbol as ScssMixin, sourceDocument); - break; - } - - case SymbolKind.Function: { - contents = makeFunctionDocumentation( - symbol as ScssFunction, - sourceDocument, - ); - break; - } - - case SymbolKind.Class: { - contents = formatPlaceholderMarkupContent( - symbol as ScssPlaceholder, - sourceDocument, - ); - break; - } - // No default - } - } - - if (contents === undefined) { - // 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 [name, { description }] of Object.entries(exports)) { - if (name === identifier.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}#${name})`, - ].join("\n"), - }, - }; - } - } - } - } - - return null; - } - - return { - contents, - }; -} - -function doSymbolHunting( - document: TextDocument, - identifier: Identifier, -): [null, null] | [ScssSymbol, IScssDocument] { - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return [null, null]; - } - - // Don't follow forwards from the current document, since the current doc doesn't have access to its symbols - for (const { link } of scssDocument.getLinks({ forwards: false })) { - const scssDocument = storage.get(link.target as string); - if (!scssDocument) { - continue; - } - - const [symbol, sourceDocument] = traverseTree(scssDocument, identifier); - if (symbol) { - return [symbol, sourceDocument]; - } - } - - // Fall back to the old way of doing things if we can't find the symbol via `@use` - for (const document of storage.values()) { - switch (identifier.kind) { - case SymbolKind.Variable: { - const variable = document.variables.get(identifier.name); - if (variable) { - return [variable, document]; - } - - break; - } - - case SymbolKind.Method: { - const mixin = document.mixins.get(identifier.name); - if (mixin) { - return [mixin, document]; - } - - break; - } - - case SymbolKind.Function: { - const func = document.functions.get(identifier.name); - if (func) { - return [func, document]; - } - - break; - } - - case SymbolKind.Class: { - const placeholder = document.placeholders.get(identifier.name); - if (placeholder) { - return [placeholder, document]; - } - } - - // No default - } - } - - return [null, null]; -} - -function traverseTree( - document: IScssDocument, - identifier: Identifier, - accumulatedPrefix = "", -): [null, null] | [ScssSymbol, IScssDocument] { - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return [null, null]; - } - - for (const symbol of scssDocument.getSymbols()) { - if (symbol.kind === SymbolKind.Class) { - // Placeholders are not namespaced the same way other symbols are - if (symbol.name === identifier.name && symbol.kind === identifier.kind) { - return [symbol, scssDocument]; - } - continue; - } - - const symbolName = `${accumulatedPrefix}${asDollarlessVariable( - symbol.name, - )}`; - const identifierName = asDollarlessVariable(identifier.name); - if (symbolName === identifierName && symbol.kind === identifier.kind) { - return [symbol, scssDocument]; - } - } - - // Check to see if we have to go deeper - // Don't follow uses, since we start with the document behind the first use, and symbols from further uses aren't available to us - // Don't follow imports, since the whole point here is to use the new module system - for (const child of scssDocument.getLinks({ - uses: false, - imports: false, - })) { - if (!child.link.target || child.link.target === scssDocument.uri) { - continue; - } - - const childDocument = storage.get(child.link.target); - if (!childDocument) { - continue; - } - - let prefix = accumulatedPrefix; - if ((child as ScssForward).prefix) { - prefix += (child as ScssForward).prefix; - } - - const [symbol, document] = traverseTree(childDocument, identifier, prefix); - if (symbol) { - return [symbol, document]; - } - } - - return [null, null]; -} diff --git a/packages/language-server/src/features/hover/index.ts b/packages/language-server/src/features/hover/index.ts deleted file mode 100644 index a6abd9ea..00000000 --- a/packages/language-server/src/features/hover/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./hover"; diff --git a/packages/language-server/src/features/references/index.ts b/packages/language-server/src/features/references/index.ts deleted file mode 100644 index dca97ed5..00000000 --- a/packages/language-server/src/features/references/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./references"; diff --git a/packages/language-server/src/features/references/references.ts b/packages/language-server/src/features/references/references.ts deleted file mode 100644 index 848f1c25..00000000 --- a/packages/language-server/src/features/references/references.ts +++ /dev/null @@ -1,417 +0,0 @@ -import type { TextDocument } from "vscode-languageserver-textdocument"; -import type { Position, ReferenceContext } from "vscode-languageserver-types"; -import { Location, Range, SymbolKind } from "vscode-languageserver-types"; -import { useContext } from "../../context-provider"; -import type { INode, IScssDocument, ScssSymbol } from "../../parser"; -import { NodeType, tokenizer } from "../../parser"; -import type StorageService from "../../storage"; -import { - asDollarlessVariable, - stripTrailingComma, - stripParentheses, -} from "../../utils/string"; -import { getDefinitionSymbol } from "../go-definition/go-definition"; -import { sassBuiltInModules } from "../sass-built-in-modules"; - -type References = { - definition: { - symbol: ScssSymbol; - document: IScssDocument; - } | null; - references: Reference[]; -}; - -type Reference = { - isBuiltIn: boolean; - name: string; - location: Location; - kind: SymbolKind | null; -}; - -export async function provideReferences( - document: TextDocument, - offset: number, - context: ReferenceContext, -): Promise { - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return null; - } - - const referenceNode = scssDocument.getNodeAt(offset); - if (!referenceNode || !referenceNode.type) { - return null; - } - - const referenceIdentifier = getIdentifier(document, referenceNode, context); - if (!referenceIdentifier) { - return null; - } - - let definitionSymbol: ScssSymbol | null = null; - let definitionDocument: IScssDocument | null = null; - - // Check to see if the current document is the one declaring the symbol before we go looking through the project - for (const symbol of scssDocument.getSymbols()) { - const symbolName = asDollarlessVariable(symbol.name); - const identifierName = asDollarlessVariable(referenceIdentifier.name); - if ( - symbolName === identifierName && - symbol.kind === referenceIdentifier.kind - ) { - definitionSymbol = symbol; - definitionDocument = scssDocument; - } - } - - if (!definitionSymbol || !definitionDocument) { - [definitionSymbol, definitionDocument] = getDefinitionSymbol( - document, - referenceIdentifier, - ); - } - - let builtin: [string, string] | null = null; - if (!definitionSymbol || !definitionDocument) { - // If we don't have a definition 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 [name] of Object.entries(exports)) { - if (name === referenceIdentifier.name) { - builtin = [module.split(":")[1] as string, name]; - } - } - } - - if (!builtin) { - return null; - } - } - - if (!builtin && !definitionDocument && !definitionSymbol) { - return null; - } - - const references: Reference[] = []; - for (const scssDocument of storage.values()) { - const text = scssDocument.getText(); - const tokens = tokenizer(text); - - for (const [tokenType, text, offset] of tokens) { - if (tokenType !== "word" && tokenType !== "brackets") { - continue; - } - - const dollarlessDefinition = stripTrailingComma( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - asDollarlessVariable(builtin ? builtin[1] : definitionSymbol!.name), - ); - - const isMatch = text.includes(dollarlessDefinition); - if (isMatch) { - // For type 'word' offset should always be defined, but default to 0 just in case - let adjustedOffset = offset || 0; - - // Tokens from maps include their trailing comma. - // Function parameters include their parentheses. Strip them both. - let word = stripParentheses(stripTrailingComma(text)); - - if (tokenType === "brackets") { - // Only include the parameter we're interested in - [word] = word - .split(",") - .filter((w) => w.includes(dollarlessDefinition)); - } - let adjustedText = word; - - // The tokenizer treats the namespace and variable name as a single word. - // We need the offset for the actual variable, so find its position in the word. - if (adjustedText !== referenceIdentifier.name) { - adjustedText = adjustedText.split(".")[1] || adjustedText; - adjustedOffset += text.indexOf(adjustedText); - } - - // Make sure we use the correct one. - // We do this in case the same identifier name is used in more than one namespace. - // foo.$var is not the same as bar.$var. - const definition = getDefinition( - scssDocument, - adjustedOffset, - storage, - context, - ); - if (!definition || !definitionDocument || !definitionSymbol) { - // If we don't have a definition anywhere we might be dealing with a built-in. - // If that's the case, add the reference even without the definition. - if (builtin) { - const [module, exports] = builtin; - // Only support modern modules with this feature as well. - if (text === `${module}.${exports}`) { - const reference = createReference( - scssDocument, - adjustedOffset, - adjustedText, - ); - references.push({ - isBuiltIn: true, - name: adjustedText, - location: reference, - kind: null, - }); - } - } - - continue; - } - - // If the files and position matches between the definition of the current token - // and the definition we found to begin with, we have a reference to report. - const isSameFile = await isSameRealPath( - definition.uri, - definitionDocument, - storage, - ); - if ( - isSameFile && - isSamePosition(definitionSymbol.position, definition.range.start) - ) { - const reference = createReference( - scssDocument, - adjustedOffset, - adjustedText, - ); - references.push({ - isBuiltIn: false, - location: reference, - name: adjustedText, - kind: definitionSymbol.kind, - }); - } - } - } - } - - return { - references, - definition: - definitionSymbol && definitionDocument - ? { - symbol: definitionSymbol, - document: definitionDocument, - } - : null, - }; -} - -interface Identifier { - kind: SymbolKind; - position: Position; - name: string; -} - -function createReference( - scssDocument: IScssDocument, - adjustedOffset: number, - adjustedText: string, -): Location { - const start = scssDocument.positionAt(adjustedOffset); - const end = scssDocument.positionAt(adjustedOffset + adjustedText.length); - const range = Range.create(start, end); - const location = Location.create(scssDocument.uri, range); - return location; -} - -function getIdentifier( - document: TextDocument, - hoverNode: INode, - context: ReferenceContext, -): Identifier | null { - let identifier: Identifier | null = null; - - if (hoverNode.type === NodeType.VariableName) { - if (!context.includeDeclaration) { - const parent = hoverNode.getParent(); - if (parent.type === NodeType.VariableDeclaration) { - return null; - } - } - - return { - name: hoverNode.getName(), - position: document.positionAt(hoverNode.offset), - kind: SymbolKind.Variable, - }; - } else if (hoverNode.type === NodeType.Identifier) { - if (hoverNode.getParent()?.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 as - // both a function and a mixin. - - const candidateIdentifier: Identifier = { - name: hoverNode.getText(), - position: document.positionAt(hoverNode.offset), - kind: SymbolKind.Method, - }; - - const [asMixin] = getDefinitionSymbol(document, candidateIdentifier); - - if (asMixin) { - return candidateIdentifier; - } - - candidateIdentifier.kind = SymbolKind.Function; - - const [asFunction] = getDefinitionSymbol(document, candidateIdentifier); - - if (asFunction) { - return candidateIdentifier; - } - - return null; - } - - let i = 0; - let node = hoverNode; - let isMixin = false; - let isFunction = false; - - while (!isMixin && !isFunction && i !== 2) { - node = node.getParent(); - - isMixin = node.type === NodeType.MixinReference; - isFunction = node.type === NodeType.Function; - - if (context.includeDeclaration) { - isMixin = isMixin || node.type === NodeType.MixinDeclaration; - isFunction = isFunction || node.type === NodeType.FunctionDeclaration; - } - - i++; - } - - if (node && (isMixin || isFunction)) { - let kind: SymbolKind = SymbolKind.Method; - - if (isFunction) { - kind = SymbolKind.Function; - } - - identifier = { - name: node.getName(), - position: document.positionAt(node.offset), - kind, - }; - } - } else if (hoverNode.type === NodeType.SelectorPlaceholder) { - identifier = { - name: hoverNode.getText(), - position: document.positionAt(hoverNode.offset), - kind: SymbolKind.Class, - }; - } - - if (!identifier) { - return null; - } - - return identifier; -} - -function getDefinition( - scssDocument: IScssDocument, - offset: number, - storage: StorageService, - context: ReferenceContext, -): Location | null { - const definitionNode = scssDocument.getNodeAt(offset); - if (!definitionNode || !definitionNode.type) { - return null; - } - - const definitionIdentifier = getIdentifier( - scssDocument, - definitionNode, - context, - ); - if (!definitionIdentifier) { - return null; - } - - const [definitionSymbol, definitionDocument] = getDefinitionSymbol( - scssDocument, - definitionIdentifier, - ); - if (!definitionSymbol || !definitionDocument) { - return null; - } - - const definitionSymbolLocation = Location.create(definitionDocument.uri, { - start: definitionSymbol.position, - end: { - line: definitionSymbol.position.line, - character: - definitionSymbol.position.character + definitionSymbol.name.length, - }, - }); - - return definitionSymbolLocation; -} - -/** - * In certain workpaces, like monorepos, you may have a local file symlinked - * and referenced via node_modules. In those cases we want to compare the - * original non-symlinked files on disk. If the filename is the same, try - * to look up the _real_ path and compare that. - * - * @param link - * @param referenced - * @returns - */ -async function isSameRealPath( - link: string, - referenced: IScssDocument, - storage: StorageService, -): Promise { - if (!link) { - return false; - } - - // Checking the file system is expensive, so do the optimistic thing first. - // If the URIs match, we're good. - if (link === referenced.uri) { - return true; - } - - if (link.includes(referenced.fileName)) { - try { - const linkedDocument = storage.get(link); - if (!linkedDocument) { - return false; - } - - const realLinkFsPath = await linkedDocument.getRealPath(); - if (!realLinkFsPath) { - return false; - } - - const realReferencedPath = await referenced.getRealPath(); - if (!realReferencedPath) { - return false; - } - - if (realLinkFsPath === realReferencedPath) { - return true; - } - } catch { - // Guess it really doesn't exist - } - } - - return false; -} - -function isSamePosition(a: Position, b: Position): boolean { - return a.character === b.character && a.line === b.line; -} diff --git a/packages/language-server/src/features/rename/index.ts b/packages/language-server/src/features/rename/index.ts deleted file mode 100644 index 0d313cde..00000000 --- a/packages/language-server/src/features/rename/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./rename"; diff --git a/packages/language-server/src/features/rename/rename.ts b/packages/language-server/src/features/rename/rename.ts deleted file mode 100644 index 9499b90f..00000000 --- a/packages/language-server/src/features/rename/rename.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { TextDocument } from "vscode-languageserver-textdocument"; -import { - Range, - SymbolKind, - TextEdit, - WorkspaceEdit, -} from "vscode-languageserver-types"; -import { useContext } from "../../context-provider"; -import { createCompletionContext } from "../completion/completion-context"; -import { provideReferences } from "../references"; - -const defaultBehavior = { defaultBehavior: true }; - -export async function prepareRename( - document: TextDocument, - offset: number, -): Promise< - null | { defaultBehavior: boolean } | { range: Range; placeholder: string } -> { - const { settings, storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return defaultBehavior; - } - - const context = createCompletionContext( - scssDocument, - scssDocument.getText(), - offset, - settings, - ); - - const referenceNode = scssDocument.getNodeAt(offset); - if (!referenceNode || !referenceNode.type) { - return defaultBehavior; - } - - const range = scssDocument.getNodeRange(referenceNode); - - const references = await provideReferences(document, offset, { - includeDeclaration: true, - }); - - if (!references) { - if (context.import) { - // 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].isBuiltIn) { - return defaultBehavior; - } - - // 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 - ) { - range.start.character += 1; - } - - // Exclude any forward-prefixes from the renaming. - if (references.definition) { - const renamingName = referenceNode.getText(); - const definitionName = references.definition.symbol.name; - if (renamingName !== definitionName) { - const diff = renamingName.length - definitionName.length; - range.start.character += diff; - } - } - - return { - range: range, - placeholder: scssDocument.getText(range), - }; -} - -export async function doRename( - document: TextDocument, - offset: number, - newName: string, -): Promise { - const references = await provideReferences(document, offset, { - includeDeclaration: true, - }); - if (!references) { - 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.definition) { - const definitionName = references.definition.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-server/src/features/signature-help/facts.ts b/packages/language-server/src/features/signature-help/facts.ts deleted file mode 100644 index 7ec118a1..00000000 --- a/packages/language-server/src/features/signature-help/facts.ts +++ /dev/null @@ -1,91 +0,0 @@ -export const colorProposals = [ - "red", - "green", - "blue", - "mix", - "hue", - "saturation", - "lightness", - "adjust-hue", - "lighten", - "darken", - "saturate", - "desaturate", - "grayscale", - "complement", - "invert", - "alpha", - "opacity", - "rgba", - "opacify", - "fade-in", - "transparentize", - "adjust-color", - "scale-color", - "change-color", - "ie-hex-str", -]; - -export const selectorFunctions = [ - "selector-nest", - "selector-append", - "selector-extend", - "selector-replace", - "selector-unify", - "is-superselector", - "simple-selectors", - "selector-parse", -]; - -export const builtInFunctions = [ - "unquote", - "quote", - "str-length", - "str-insert", - "str-index", - "str-slice", - "to-upper-case", - "to-lower-case", - "percentage", - "round", - "ceil", - "floor", - "abs", - "min", - "max", - "random", - "length", - "nth", - "set-nth", - "join", - "append", - "zip", - "index", - "list-separator", - "map-get", - "map-merge", - "map-remove", - "map-keys", - "map-values", - "map-has-key", - "keywords", - "feature-exists", - "variable-exists", - "global-variable-exists", - "function-exists", - "mixin-exists", - "inspect", - "type-of", - "unit", - "unitless", - "comparable", - "call", -]; - -export function hasInFacts(word: string): boolean { - return ( - colorProposals.includes(word) || - selectorFunctions.includes(word) || - builtInFunctions.includes(word) - ); -} diff --git a/packages/language-server/src/features/signature-help/index.ts b/packages/language-server/src/features/signature-help/index.ts deleted file mode 100644 index 10e45724..00000000 --- a/packages/language-server/src/features/signature-help/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./signature-help"; diff --git a/packages/language-server/src/features/signature-help/signature-help.ts b/packages/language-server/src/features/signature-help/signature-help.ts deleted file mode 100644 index abc7c963..00000000 --- a/packages/language-server/src/features/signature-help/signature-help.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { tokenizer } from "scss-symbols-parser"; -import { MarkupKind, SignatureInformation } from "vscode-languageserver"; -import type { SignatureHelp } from "vscode-languageserver"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { useContext } from "../../context-provider"; -import type { - IScssDocument, - ScssForward, - ScssFunction, - ScssMixin, -} from "../../parser"; -import { applySassDoc } from "../../utils/sassdoc"; -import { - asDollarlessVariable, - getTextBeforePosition, -} from "../../utils/string"; -import { sassBuiltInModules } from "../sass-built-in-modules"; -import { hasInFacts } from "./facts"; - -// RegExp's -const reNestedParenthesis = /\(([\w-]+)\(/; -const reSymbolName = /[\w-]+$/; - -interface IMixinEntry { - name: string | null; - parameters: number; -} - -/** - * Returns name of last Mixin or Function in the string. - */ -function getSymbolName(text: string): string | null | undefined { - const tokens = tokenizer(text); - let pos = tokens.length; - let token: [string, string, string]; - let parenthesisCount = 0; - - while (pos !== 0) { - pos--; - token = tokens[pos]; - - // Return first `word` token before `(` because it's Symbols name - if (token[0] === "(") { - // Skip nested parenthesis - parenthesisCount--; - if (parenthesisCount > -1) { - continue; - } - - // String can be contains built-in Functions such as `rgba` or `map` - while (pos !== 0) { - pos--; - token = tokens[pos]; - - if (token[0] === "word" && !hasInFacts(token[1])) { - const match = reSymbolName.exec(token[1]); - return match ? match[0] : null; - } - } - } else if (token[0] === ")") { - parenthesisCount++; - } else if (token[0] === "brackets" && reNestedParenthesis.test(token[1])) { - // Tokens for nested string with correct positions - const nestedTokens = tokenizer(token[1]).map((x) => { - if (x.length === 3) { - x[2] += token[2]; - } - - return x; - }); - - // Replace the current token on a new collection - tokens.splice(pos, 1, ...nestedTokens); - // Revert position back on length of nested tokens - pos += nestedTokens.length; - } - } - - return null; -} - -/** - * Returns Mixin name and its parameters from line. - */ -function parseArgumentsAtLine(text: string): IMixinEntry { - const indexOfOpenCurly = text.indexOf("{"); - if (indexOfOpenCurly !== -1) { - text = text.slice(indexOfOpenCurly + 1, text.length); - } - - text = text.trim(); - - // Try to find name of Mixin or Function - const name = getSymbolName(text); - - let paramsString = ""; - if (name) { - const start = text.lastIndexOf(`${name}(`) + name.length; - paramsString = text.slice(start, text.length); - } - - let parameters = 0; - if (paramsString.slice(1).length > 0) { - const tokens = tokenizer(paramsString); - - if (tokens.length === 1 && tokens[0][0] === "brackets") { - return { - name: null, - parameters, - }; - } - - let pos = 0; - let token; - let parenthesis = -1; - - while (pos < tokens.length) { - token = tokens[pos]; - - if (token[1] === "," || token[1] === ";") { - parameters++; - } else if ( - token[0] === "word" && - token[1] !== "," && - token[1].includes(",") && - parenthesis === 0 - ) { - const words: string[] = token[1].split(/(,)/); - - let index = pos; - words.forEach((word) => { - if (word === "") { - return; - } - - tokens.splice( - index, - 1, - word === "," ? [",", ",", 0] : ["word", word, 0], - ); - index++; - }); - } else if (token[0] === "(") { - parenthesis++; - } else if (token[0] === ")") { - parenthesis--; - } - - pos++; - } - } - - return { - name: name ? name : null, - parameters, - }; -} - -export async function doSignatureHelp( - document: TextDocument, - offset: number, -): Promise { - const ret: SignatureHelp = { - activeSignature: 0, - activeParameter: 0, - signatures: [], - }; - - // Skip suggestions if the text not include `(` or include `);` - const textBeforeWord = getTextBeforePosition(document.getText(), offset); - if (textBeforeWord.endsWith(");") || !textBeforeWord.includes("(")) { - return ret; - } - - const entry = parseArgumentsAtLine(textBeforeWord); - if (!entry.name) { - return ret; - } - - ret.activeParameter = Math.max(0, entry.parameters); - - const symbolType = textBeforeWord.includes("@include") ? "mixin" : "function"; - - const suggestions: Array = doSymbolHunting( - document, - entry, - symbolType, - ); - - if (suggestions.length === 0) { - // Look for built-ins - for (const { reference, exports } of Object.values(sassBuiltInModules)) { - for (const [name, { signature, description }] of Object.entries( - exports, - )) { - if (name === entry.name) { - // 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 = textBeforeWord.includes(`.${name}(`); - 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 })); - } - - ret.signatures.push(signatureInfo); - break; - } - } - } - - return ret; - } - - for (const symbol of suggestions) { - const paramsString = symbol.parameters - .map((x) => `${x.name}: ${x.value}`) - .join(", "); - const signatureInfo = SignatureInformation.create( - `${symbol.name} (${paramsString})`, - ); - - const sassdoc = applySassDoc(symbol); - - signatureInfo.documentation = { - kind: MarkupKind.Markdown, - value: sassdoc, - }; - - symbol.parameters.forEach((param) => { - signatureInfo.parameters?.push({ - label: param.name, - documentation: "", - }); - }); - - ret.signatures.push(signatureInfo); - } - - return ret; -} - -function doSymbolHunting( - document: TextDocument, - entry: IMixinEntry, - entryType: "function" | "mixin", -): Array { - const result: Array = []; - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return result; - } - - // Don't follow forwards from the current document, since the current doc doesn't have access to its symbols - for (const { link } of scssDocument.getLinks({ forwards: false })) { - const scssDocument = storage.get(link.target as string); - if (!scssDocument) { - continue; - } - - traverseTree(scssDocument, result, entry, entryType); - } - - if (result.length === 0) { - // If we didn't find any symbols with the modern method, fall back to the old way of searching - for (const scssDocument of storage.values()) { - const symbols = - entryType === "mixin" - ? scssDocument.mixins.values() - : scssDocument.functions.values(); - for (const symbol of symbols) { - if ( - entry.name === symbol.name && - symbol.parameters.length >= entry.parameters - ) { - result.push(symbol); - } - } - } - } - - return result; -} - -function traverseTree( - document: IScssDocument, - result: Array, - entry: IMixinEntry, - entryType: "function" | "mixin", - accumulatedPrefix = "", -): Array { - const { storage } = useContext(); - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - return result; - } - - const entryName = asDollarlessVariable(entry.name as string); - const symbols = - entryType === "mixin" - ? scssDocument.mixins.values() - : scssDocument.functions.values(); - for (const symbol of symbols) { - const symbolName = `${accumulatedPrefix}${asDollarlessVariable( - symbol.name, - )}`; - if ( - symbolName === entryName && - symbol.parameters.length >= entry.parameters && - !result.find((x) => x.name === symbol.name) - ) { - result.push(symbol); - } - } - - // Check to see if we have to go deeper - // Don't follow uses, since we start with the document behind the first use, and symbols from further uses aren't available to us - // Don't follow imports, since the whole point here is to use the new module system - for (const child of scssDocument.getLinks({ - uses: false, - imports: false, - })) { - if (!child.link.target || child.link.target === scssDocument.uri) { - continue; - } - - const childDocument = storage.get(child.link.target); - if (!childDocument) { - continue; - } - - let prefix = accumulatedPrefix; - if ((child as ScssForward).prefix) { - prefix += (child as ScssForward).prefix; - } - - traverseTree(childDocument, result, entry, entryType, prefix); - } - - return result; -} diff --git a/packages/language-server/src/features/workspace-symbols/index.ts b/packages/language-server/src/features/workspace-symbols/index.ts deleted file mode 100644 index 6ef0c11f..00000000 --- a/packages/language-server/src/features/workspace-symbols/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./workspace-symbol"; diff --git a/packages/language-server/src/features/workspace-symbols/workspace-symbol.ts b/packages/language-server/src/features/workspace-symbols/workspace-symbol.ts deleted file mode 100644 index 17877173..00000000 --- a/packages/language-server/src/features/workspace-symbols/workspace-symbol.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { SymbolInformation } from "vscode-languageserver"; -import { useContext } from "../../context-provider"; - -export async function searchWorkspaceSymbol( - query: string, - root: string, -): Promise { - const workspaceSymbols: SymbolInformation[] = []; - const { storage } = useContext(); - for (const scssDocument of storage.values()) { - if (!scssDocument.uri.includes(root)) { - continue; - } - - for (const symbol of scssDocument.getSymbols()) { - if (symbol.position === undefined || !symbol.name.includes(query)) { - continue; - } - - workspaceSymbols.push({ - name: symbol.name, - kind: symbol.kind, - location: { - uri: scssDocument.uri, - range: { - start: symbol.position, - end: { - line: symbol.position.line, - character: symbol.position.character + symbol.name.length, - }, - }, - }, - }); - } - } - - return workspaceSymbols; -} diff --git a/packages/language-server/src/file-system-provider.ts b/packages/language-server/src/file-system-provider.ts index 2b446182..38109e54 100644 --- a/packages/language-server/src/file-system-provider.ts +++ b/packages/language-server/src/file-system-provider.ts @@ -1,4 +1,4 @@ -import { type FileStat, FileType } from "vscode-css-languageservice"; +import { type FileStat, FileType } from "@somesass/language-services"; import { type Connection, RequestType } from "vscode-languageserver"; import { URI } from "vscode-uri"; import { @@ -73,7 +73,7 @@ export function getFileSystemProvider( }); return res; }, - async readDirectory(uri: string) { + async readDirectory(uri: URI) { const handler = runtime.file; if (handler) { return await handler.readDirectory(uri); diff --git a/packages/language-server/src/file-system.ts b/packages/language-server/src/file-system.ts index 6d5f9667..0b542292 100644 --- a/packages/language-server/src/file-system.ts +++ b/packages/language-server/src/file-system.ts @@ -1,4 +1,4 @@ -import { FileStat, FileType } from "vscode-css-languageservice"; +import { FileStat, FileType } from "@somesass/language-services"; import type { CancellationToken } from "vscode-languageserver"; import type { URI } from "vscode-uri"; @@ -14,7 +14,7 @@ export interface FileSystemProvider { token?: CancellationToken, ): Promise; readFile(uri: URI, encoding?: BufferEncoding): Promise; - readDirectory(uri: string): Promise<[string, FileType][]>; + readDirectory(uri: URI): Promise<[string, FileType][]>; stat(uri: URI): Promise; realPath(uri: URI): Promise; } diff --git a/packages/language-server/src/node-file-system.ts b/packages/language-server/src/node-file-system.ts index dbd4fb2e..9e7a8cb5 100644 --- a/packages/language-server/src/node-file-system.ts +++ b/packages/language-server/src/node-file-system.ts @@ -1,6 +1,6 @@ import { promises, constants, existsSync } from "fs"; +import { type FileStat, FileType } from "@somesass/language-services"; import * as fg from "fast-glob"; -import { type FileStat, FileType } from "vscode-css-languageservice"; import { URI, Utils } from "vscode-uri"; import type { FileSystemProvider } from "./file-system"; @@ -40,15 +40,15 @@ export class NodeFileSystem implements FileSystemProvider { return promises.readFile(uri.fsPath, encoding); } - async readDirectory(uri: string): Promise<[string, FileType][]> { - const dir = await promises.readdir(uri); + async readDirectory(uri: URI): Promise<[string, FileType][]> { + const dir = await promises.readdir(uri.fsPath); const result: [string, FileType][] = []; - for (const file of dir) { + for (const name of dir) { try { - const stats = await this.stat(Utils.joinPath(URI.parse(uri), file)); - result.push([file, stats.type]); + const stats = await this.stat(Utils.joinPath(uri, name)); + result.push([name, stats.type]); } catch (e) { - result.push([file, FileType.Unknown]); + result.push([name, FileType.Unknown]); } } return result; diff --git a/packages/language-server/src/parser/ast.ts b/packages/language-server/src/parser/ast.ts deleted file mode 100644 index c8833550..00000000 --- a/packages/language-server/src/parser/ast.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { INode, NodeType } from "./node"; - -/** - * Get Node by offset position. - */ -export function getNodeAtOffset( - parsedDocument: INode, - posOffset: number | null, -): INode | null { - let candidate: INode | null = null; - - parsedDocument.accept((node) => { - if (node.offset === -1 && node.length === -1) { - return true; - } - - if ( - posOffset !== null && - node.offset <= posOffset && - node.end >= posOffset - ) { - if (!candidate) { - candidate = node; - } else if (node.length <= candidate.length) { - candidate = node; - } - - return true; - } - - return false; - }); - - return candidate; -} - -/** - * Returns the parent Node of the specified type. - */ -export function getParentNodeByType( - node: INode | null, - type: NodeType, -): INode | null { - if (node === null) { - return null; - } - - node = node.getParent(); - - while (node.type !== type) { - if (node.type === NodeType.Stylesheet) { - return null; - } - - node = node.getParent(); - } - - return node; -} diff --git a/packages/language-server/src/parser/document.ts b/packages/language-server/src/parser/document.ts deleted file mode 100644 index 9a02ec84..00000000 --- a/packages/language-server/src/parser/document.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { DocumentContext } from "vscode-css-languageservice"; -import { URI, Utils } from "vscode-uri"; - -export function buildDocumentContext( - documentUri: string, - workspaceRoot: URI, -): DocumentContext { - function getRootFolder(): string | undefined { - let folderURI = workspaceRoot.toString(); - if (!folderURI.endsWith("/")) { - folderURI += "/"; - } - - if (documentUri.startsWith(folderURI)) { - return folderURI; - } - - return undefined; - } - - return { - resolveReference: (ref, base = documentUri) => { - if ( - ref.startsWith("/") && // Resolve absolute path against the current workspace folder - base.startsWith("file://") // Only support this extra custom resolving in a Node environment - ) { - const folderUri = getRootFolder(); - if (folderUri) { - return folderUri + ref.slice(1); - } - } - base = base.substr(0, base.lastIndexOf("/") + 1); - return Utils.resolvePath(URI.parse(base), ref).toString(); - }, - }; -} diff --git a/packages/language-server/src/parser/index.ts b/packages/language-server/src/parser/index.ts deleted file mode 100644 index 902aa991..00000000 --- a/packages/language-server/src/parser/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./node"; -export * from "./parser"; -export * from "./scss-document"; -export * from "./scss-symbol"; -export * from "./tokenizer"; diff --git a/packages/language-server/src/parser/language-service.ts b/packages/language-server/src/parser/language-service.ts deleted file mode 100644 index ea2d3929..00000000 --- a/packages/language-server/src/parser/language-service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { FileType, getSCSSLanguageService } from "vscode-css-languageservice"; -import type { - LanguageService, - FileSystemProvider as CSSFileSystemProvider, -} from "vscode-css-languageservice"; -import { URI } from "vscode-uri"; -import { useContext } from "../context-provider"; - -let ls: LanguageService; - -export function getLanguageService(): LanguageService { - if (ls) { - return ls; - } - - const { fs, clientCapabilities } = useContext(); - - const fileSystemProvider: CSSFileSystemProvider = { - readDirectory(uri) { - return fs.readDirectory(uri); - }, - async stat(uri: string) { - try { - return await fs.stat(URI.parse(uri)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - return { - type: FileType.Unknown, - ctime: -1, - mtime: -1, - size: -1, - }; - } - }, - }; - - ls = getSCSSLanguageService({ fileSystemProvider, clientCapabilities }); - - ls.configure({ - validate: false, - }); - - return ls; -} diff --git a/packages/language-server/src/parser/node.ts b/packages/language-server/src/parser/node.ts deleted file mode 100644 index c5037bbc..00000000 --- a/packages/language-server/src/parser/node.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Must be synced with https://github.com/microsoft/vscode-css-languageservice/blob/main/src/parser/cssNodes.ts - * when upgrading vscode-css-languageservice. - */ -export enum NodeType { - Undefined, - Identifier, - Stylesheet, - Ruleset, - Selector, - SimpleSelector, - SelectorInterpolation, - SelectorCombinator, - SelectorCombinatorParent, - SelectorCombinatorSibling, - SelectorCombinatorAllSiblings, - SelectorCombinatorShadowPiercingDescendant, - Page, - PageBoxMarginBox, - ClassSelector, - IdentifierSelector, - ElementNameSelector, - PseudoSelector, - AttributeSelector, - Declaration, - Declarations, - Property, - Expression, - BinaryExpression, - Term, - Operator, - Value, - StringLiteral, - URILiteral, - EscapedValue, - Function, - NumericValue, - HexColorValue, - RatioValue, - MixinDeclaration, - MixinReference, - VariableName, - VariableDeclaration, - Prio, - Interpolation, - NestedProperties, - ExtendsReference, - SelectorPlaceholder, - Debug, - If, - Else, - For, - Each, - While, - MixinContentReference, - MixinContentDeclaration, - Media, - Keyframe, - FontFace, - Import, - Namespace, - Invocation, - FunctionDeclaration, - ReturnStatement, - MediaQuery, - MediaCondition, - MediaFeature, - FunctionParameter, - FunctionArgument, - KeyframeSelector, - ViewPort, - Document, - AtApplyRule, - CustomPropertyDeclaration, - CustomPropertySet, - ListEntry, - Supports, - SupportsCondition, - NamespacePrefix, - GridLine, - Plugin, - UnknownAtRule, - Use, - ModuleConfiguration, - Forward, - ForwardVisibility, - Module, - UnicodeRange, - Layer, - LayerNameList, - LayerName, - PropertyAtRule, - Container, -} - -export interface INode { - type: NodeType; - offset: number; - length: number; - end: number; - accept: (node: (node: INode) => boolean) => boolean; - getName: () => string; - getValue: () => INode; - getDefaultValue: () => INode; - getText: () => string; - getParameters: () => INode; - getIdentifier: () => INode; - getParent: () => INode; - getChildren: () => INode[]; - getChild: (index: number) => INode; - getSelectors: () => INode; -} diff --git a/packages/language-server/src/parser/parser.ts b/packages/language-server/src/parser/parser.ts deleted file mode 100644 index 600929b2..00000000 --- a/packages/language-server/src/parser/parser.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { parse, type ParseResult } from "scss-sassdoc-parser"; -import { - Position, - Range, - SymbolKind, - DocumentLink, - LanguageService, -} from "vscode-css-languageservice"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { URI } from "vscode-uri"; -import { useContext } from "../context-provider"; -import { sassBuiltInModuleNames } from "../features/sass-built-in-modules"; -import type { FileSystemProvider } from "../file-system"; -import { asDollarlessVariable, getLinesFromText } from "../utils/string"; -import { getNodeAtOffset, getParentNodeByType } from "./ast"; -import { buildDocumentContext } from "./document"; -import { getLanguageService } from "./language-service"; -import { INode, NodeType } from "./node"; -import { ScssDocument } from "./scss-document"; -import type { IScssSymbols } from "./scss-symbol"; - -export const reModuleAtRule = /@(?:use|forward|import)/; -export const reUse = /@use ["'|](?.+)["'|](?: as (?\*|\w+))?;/; -export const reForward = - /@forward ["'|](?.+)["'|](?: as (?\w+-)\*)?(?: hide (?.+))?(?: show (?.+))?;/; -export const reImport = /@import ["'|](?.+)["'|]/; -export const rePlaceholder = /^\s*%(?\w+)/; -export const rePlaceholderUsage = /\s*@extend\s+(?%[\w\d-_]+)/; - -const reDynamicPath = /[#*{}]/; - -export async function parseDocument( - document: TextDocument, - workspaceRoot: URI, -): Promise { - const { fs } = useContext(); - const ls = getLanguageService(); - const ast = ls.parseStylesheet(document) as INode; - const symbols = await findDocumentSymbols( - document, - ast, - workspaceRoot, - fs, - ls, - ); - return new ScssDocument(fs, document, symbols, ast); -} - -async function findDocumentSymbols( - document: TextDocument, - ast: INode, - workspaceRoot: URI, - fs: FileSystemProvider, - ls: LanguageService, -): Promise { - const result: IScssSymbols = { - functions: new Map(), - mixins: new Map(), - variables: new Map(), - imports: new Map(), - uses: new Map(), - forwards: new Map(), - placeholders: new Map(), - placeholderUsages: new Map(), - }; - - const links = await ls.findDocumentLinks2( - document, - ast, - buildDocumentContext(document.uri, workspaceRoot), - ); - - const text = document.getText(); - const lines = getLinesFromText(text); - - for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { - const line = lines.at(lineNumber); - if (typeof line === "undefined") { - continue; - } - - for (const link of links) { - if ( - !link.target || - link.target.endsWith(".css") || - !reModuleAtRule.test(line) - ) { - continue; - } - - link.target = ensureScssExtension(link.target); - - const targetUri = URI.parse(link.target); - const targetExists = await fs.exists(targetUri); - if (!targetExists) { - // The target string may be a partial without its _ prefix, - // so try looking for it by that name. - const partial = ensurePartial(link.target); - const partialUri = URI.parse(partial); - const partialExists = await fs.exists(partialUri); - if (!partialExists) { - // We tried to resolve the file as a partial, but it doesn't exist. - // The target string may be a folder with an index file - // so try looking for it by that name. - const index = ensureIndex(link.target); - const indexUri = URI.parse(index); - const indexExists = await fs.exists(indexUri); - if (!indexExists) { - const partialIndex = ensurePartial(ensureIndex(link.target)); - const partialIndexUri = URI.parse(partialIndex); - const partialIndexExists = await fs.exists(partialIndexUri); - if (!partialIndexExists) { - // We tried, this file doesn't exist - continue; - } else { - link.target = partialIndexUri.toString(); - } - } else { - link.target = indexUri.toString(); - } - } else { - link.target = partialUri.toString(); - } - } else { - link.target = targetUri.toString(); - } - - const matchUse = reUse.exec(line); - if (matchUse) { - const url = matchUse.groups?.["url"]; - if (urlMatches(url as string, link.target)) { - const namespace = matchUse.groups?.["namespace"]; - link.target = await toRealPath(link.target, fs); - result.uses.set(link.target, { - link, - namespace: namespace || getNamespaceFromLink(link), - isAliased: Boolean(namespace), - }); - } - - continue; - } - - const matchForward = reForward.exec(line); - if (matchForward) { - const url = matchForward.groups?.["url"]; - if (urlMatches(url as string, link.target)) { - link.target = await toRealPath(link.target, fs); - result.forwards.set(link.target, { - link, - prefix: matchForward.groups?.["prefix"], - hide: matchForward.groups?.["hide"] - ? matchForward.groups["hide"].split(",").map((s) => s.trim()) - : [], - show: matchForward.groups?.["show"] - ? matchForward.groups["show"].split(",").map((s) => s.trim()) - : [], - }); - } - - continue; - } - - const matchImport = reImport.exec(line); - if (matchImport) { - const url = matchImport.groups?.["url"]; - if (urlMatches(url as string, link.target)) { - link.target = await toRealPath(link.target, fs); - result.imports.set(link.target, { - link, - dynamic: reDynamicPath.test(link.target), - css: link.target.endsWith(".css"), - }); - } - } - } - - // Look for any usage of built-in modules like @use "sass:math"; - const matchUse = reUse.exec(line); - if (matchUse) { - const url = matchUse.groups?.["url"]; - if (!url) { - continue; - } - const isBuiltIn = sassBuiltInModuleNames.has(url); - if (isBuiltIn) { - const namespace = matchUse.groups?.["namespace"]; - result.uses.set(url, { - // Fake link with builtin as target - link: DocumentLink.create( - Range.create(Position.create(1, 1), Position.create(1, 1)), - url, - ), - namespace: namespace || url.split(":")[1], - isAliased: Boolean(namespace), - }); - } - - continue; - } - - if (rePlaceholderUsage.test(line)) { - const match = rePlaceholderUsage.exec(line); - if (match) { - const name = match.groups?.["name"]; - if (name) { - const position = Position.create(lineNumber, line.indexOf(name)); - result.placeholderUsages.set(name, { - name, - position, - offset: document.offsetAt(position), - kind: SymbolKind.Class, - }); - } - } - } - } - - let sassdoc: ParseResult[] = []; - try { - sassdoc = await parse(text); - } catch (error) { - console.error((error as Error).message); - } - - const symbols = ls.findDocumentSymbols2(document, ast); - - for (const symbol of symbols) { - const position = symbol.range.start; - const offset = document.offsetAt(symbol.range.start); - switch (symbol.kind) { - case SymbolKind.Variable: { - const dollarlessName = symbol.name.replace("$", ""); - const docs = sassdoc.find( - (v) => - v.context.name === dollarlessName && v.context.type === "variable", - ); - result.variables.set(symbol.name, { - name: symbol.name, - kind: SymbolKind.Variable, - offset, - position, - value: getVariableValue(ast, offset), - sassdoc: docs, - }); - - break; - } - - case SymbolKind.Method: { - const docs = sassdoc.find( - (v) => v.context.name === symbol.name && v.context.type === "mixin", - ); - result.mixins.set(symbol.name, { - name: symbol.name, - kind: SymbolKind.Method, - offset, - position, - parameters: getMethodParameters(ast, offset, docs), - sassdoc: docs, - }); - - break; - } - - case SymbolKind.Function: { - const docs = sassdoc.find( - (v) => - v.context.name === symbol.name && v.context.type === "function", - ); - result.functions.set(symbol.name, { - name: symbol.name, - kind: SymbolKind.Function, - offset, - position, - parameters: getMethodParameters(ast, offset, docs), - sassdoc: docs, - }); - - break; - } - - case SymbolKind.Class: { - if (symbol.name.startsWith("%")) { - const sansPercent = symbol.name.substring(1); - const docs = sassdoc.find( - (v) => - v.context.name === sansPercent && - v.context.type === "placeholder", - ); - result.placeholders.set(symbol.name, { - name: symbol.name, - kind: SymbolKind.Class, - offset, - position, - sassdoc: docs, - }); - } - break; - } - // No default - } - } - - return result; -} - -function getNamespaceFromLink(link: DocumentLink): string | undefined { - if (!link.target) { - return undefined; - } - - const lastSlash = link.target.lastIndexOf("/"); - const extension = link.target.lastIndexOf("."); - let candidate = link.target.substring(lastSlash + 1, extension); - - candidate = candidate.startsWith("_") ? candidate.slice(1) : candidate; - - if (candidate === "index") { - // The link points to an index file. Use the folder name above as a namespace. - const linkOmitIndex = link.target.slice(0, Math.max(0, lastSlash)); - const newLastSlash = linkOmitIndex.lastIndexOf("/"); - candidate = linkOmitIndex.slice(Math.max(0, newLastSlash + 1)); - } - - return candidate; -} - -function ensureScssExtension(target: string): string { - if (target.endsWith(".scss")) { - return target; - } - - return `${target}.scss`; -} - -function ensurePartial(target: string): string { - const lastSlash = target.lastIndexOf("/"); - const lastDot = target.lastIndexOf("."); - const fileName = target.substring(lastSlash + 1, lastDot); - - if (fileName.startsWith("_")) { - return target; - } - - const path = target.slice(0, Math.max(0, lastSlash + 1)); - const extension = target.slice(Math.max(0, lastDot)); - return `${path}_${fileName}${extension}`; -} - -function ensureIndex(target: string): string { - const lastSlash = target.lastIndexOf("/"); - const lastDot = target.lastIndexOf("."); - const fileName = target.substring(lastSlash + 1, lastDot); - - if (fileName.includes("index")) { - return target; - } - - const path = target.slice(0, Math.max(0, lastSlash + 1)); - const extension = target.slice(Math.max(0, lastDot)); - return `${path}/${fileName}/index${extension}`; -} - -function urlMatches(url: string, linkTarget: string): boolean { - let safeUrl = url; - while (/^[./@~]/.exec(safeUrl)) { - safeUrl = safeUrl.slice(1); - } - - let match = linkTarget.includes(safeUrl); - if (!match) { - const lastSlash = safeUrl.lastIndexOf("/"); - const toLastSlash = safeUrl.slice(0, Math.max(0, lastSlash)); - const restOfUrl = safeUrl.slice(Math.max(0, lastSlash + 1)); - const partial = `${toLastSlash}/_${restOfUrl}`; - match = linkTarget.includes(partial); - } - - return match; -} - -async function toRealPath( - target: string, - fs: FileSystemProvider, -): Promise { - const linkUri = URI.parse(target); - const realPathUri = await fs.realPath(linkUri); - return realPathUri.toString(); -} - -function getVariableValue(ast: INode, offset: number): string | null { - const node = getNodeAtOffset(ast, offset); - - if (node === null) { - return null; - } - - const parent = getParentNodeByType(node, NodeType.VariableDeclaration); - - return parent?.getValue()?.getText() || null; -} - -function getMethodParameters( - ast: INode, - offset: number, - sassDoc: ParseResult | undefined, -) { - const node = getNodeAtOffset(ast, offset); - - if (node === null) { - return []; - } - - return node - .getParameters() - .getChildren() - .map((child) => { - const defaultValueNode = child.getDefaultValue(); - - const value = - defaultValueNode === undefined ? null : defaultValueNode.getText(); - const name = child.getName(); - - const dollarlessName = asDollarlessVariable(name); - const docs = sassDoc - ? sassDoc.parameter?.find((p) => p.name === dollarlessName) - : undefined; - - return { - name, - offset: child.offset, - value, - kind: SymbolKind.Variable, - sassdoc: docs, - }; - }); -} diff --git a/packages/language-server/src/parser/scss-document.ts b/packages/language-server/src/parser/scss-document.ts deleted file mode 100644 index 1621d670..00000000 --- a/packages/language-server/src/parser/scss-document.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { TextDocument } from "vscode-languageserver-textdocument"; -import { Position, Range } from "vscode-languageserver-types"; -import { URI } from "vscode-uri"; -import type { FileSystemProvider } from "../file-system"; -import { getLinesFromText } from "../utils/string"; -import { getNodeAtOffset } from "./ast"; -import type { INode } from "./node"; -import type { - IScssDocument, - IScssSymbols, - ScssForward, - ScssFunction, - ScssImport, - ScssLink, - ScssMixin, - ScssPlaceholder, - ScssPlaceholderUsage, - ScssSymbol, - ScssUse, - ScssVariable, -} from "./scss-symbol"; - -export class ScssDocument implements IScssDocument { - public textDocument: TextDocument; - public ast: INode; - public fileName: string; - public uri: string; - - public imports: Map = new Map(); - public uses: Map = new Map(); - public forwards: Map = new Map(); - public variables: Map = new Map(); - public mixins: Map = new Map(); - public functions: Map = new Map(); - public placeholders: Map = new Map(); - public placeholderUsages: Map = new Map(); - - private fs: FileSystemProvider; - private realPath: string | null = null; - - constructor( - fs: FileSystemProvider, - document: TextDocument, - symbols: IScssSymbols, - ast: INode, - ) { - this.ast = ast; - this.fs = fs; - this.textDocument = document; - this.uri = document.uri; - this.imports = symbols.imports; - this.uses = symbols.uses; - this.forwards = symbols.forwards; - this.variables = symbols.variables; - this.mixins = symbols.mixins; - this.functions = symbols.functions; - this.placeholders = symbols.placeholders; - this.placeholderUsages = symbols.placeholderUsages; - this.fileName = this.getFileName(); - } - - public async getRealPath(): Promise { - if (this.realPath) { - return this.realPath; - } - - try { - const path = await this.fs.realPath(URI.parse(this.uri)); - this.realPath = path.fsPath; - } catch { - // Do nothing - } - - return this.realPath; - } - - private getFileName(): string { - const uri = this.textDocument.uri; - const lastSlash = uri.lastIndexOf("/"); - return lastSlash === -1 ? uri : uri.slice(Math.max(0, lastSlash + 1)); - } - - public get languageId(): string { - return this.textDocument.languageId; - } - - public get version(): number { - return this.textDocument.version; - } - - public getText(range?: Range): string { - return this.textDocument.getText(range); - } - - public getNodeAt(offset: number): INode | null { - return getNodeAtOffset(this.ast, offset); - } - - public getNodeRange(node: INode): Range { - return Range.create( - this.textDocument.positionAt(node.offset), - this.textDocument.positionAt(node.end), - ); - } - - public positionAt(offset: number): Position { - return this.textDocument.positionAt(offset); - } - - public offsetAt(position: Position): number { - return this.textDocument.offsetAt(position); - } - - public get lineCount(): number { - return this.textDocument.lineCount; - } - - public getLines(): string[] { - return getLinesFromText(this.textDocument.getText()); - } - - public getSymbols(): ScssSymbol[] { - const symbols: ScssSymbol[] = []; - - for (const variable of this.variables.values()) { - symbols.push(variable); - } - - for (const mixin of this.mixins.values()) { - symbols.push(mixin); - } - - for (const func of this.functions.values()) { - symbols.push(func); - } - - for (const placeholder of this.placeholders.values()) { - symbols.push(placeholder); - } - - return symbols; - } - - public getLinks(opts = {}): ScssLink[] { - const options = { forwards: true, uses: true, imports: true, ...opts }; - const links: ScssLink[] = []; - - if (options.imports) { - for (const imp of this.imports.values()) { - links.push(imp); - } - } - - if (options.uses) { - for (const use of this.uses.values()) { - links.push(use); - } - } - - if (options.forwards) { - for (const forward of this.forwards.values()) { - links.push(forward); - } - } - - return links; - } -} diff --git a/packages/language-server/src/parser/scss-symbol.ts b/packages/language-server/src/parser/scss-symbol.ts deleted file mode 100644 index 1689efd8..00000000 --- a/packages/language-server/src/parser/scss-symbol.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Parameter, ParseResult } from "scss-sassdoc-parser"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import type { - DocumentLink, - Position, - SymbolKind, - Range, -} from "vscode-languageserver-types"; -import type { INode } from "./node"; - -export interface ScssSymbol { - kind: SymbolKind; - name: string; - sassdoc?: ParseResult; - position: Position; - offset: number; -} - -export interface ScssVariable extends ScssSymbol { - mixin?: string; - value: string | null; -} - -export interface ScssParameter - extends Omit { - sassdoc?: Parameter; -} - -export interface ScssMixin extends ScssSymbol { - parameters: ScssParameter[]; -} - -export type ScssFunction = ScssMixin; - -export type ScssPlaceholder = ScssSymbol; -export type ScssPlaceholderUsage = ScssSymbol; - -export interface ScssLink { - link: DocumentLink; -} - -export interface ScssUse extends ScssLink { - namespace?: string; - /** Indicates whether the namespace is different from the file name. */ - isAliased: boolean; -} - -export interface ScssForward extends ScssLink { - hide: string[]; - show: string[]; - prefix?: string; -} - -export interface ScssImport extends ScssLink { - css: boolean; - dynamic: boolean; -} - -export interface IScssSymbols { - imports: Map; - uses: Map; - forwards: Map; - variables: Map; - mixins: Map; - functions: Map; - placeholders: Map; - placeholderUsages: Map; -} - -export interface IScssDocument extends TextDocument, IScssSymbols { - textDocument: TextDocument; - ast: INode; - /** - * The last part of the URI, including extension. - * For instance, given the URI `file:///home/test.scss`, - * the fileName is `test.scss`. - */ - fileName: string; - /** Find and cache the real path (as opposed to symlinked) */ - getRealPath: () => Promise; - getLinks: (options?: { - forwards?: boolean; - uses?: boolean; - imports?: boolean; - }) => ScssLink[]; - getSymbols: () => ScssSymbol[]; - getNodeAt: (offset: number) => INode | null; - getNodeRange: (node: INode) => Range; -} diff --git a/packages/language-server/src/parser/tokenizer.ts b/packages/language-server/src/parser/tokenizer.ts deleted file mode 100644 index 4fa7baaf..00000000 --- a/packages/language-server/src/parser/tokenizer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { tokenizer as scssSymbolsTokenizer } from "scss-symbols-parser"; - -type TokenType = - | ";" - | ":" - | "(" - | ")" - | "[" - | "]" - | "{" - | "}" - | "at-word" - | "brackets" - | "comment" - | "space" - | "string" - | "word"; -type TokenText = string; -type TokenTextOffset = number; - -/** - * The result from the tokenizer function in scss-symbols-parser is an array of these Tokens. - */ -export type Token = [TokenType, TokenText, TokenTextOffset | undefined]; - -export function tokenizer(string: string): Token[] { - return scssSymbolsTokenizer(string); -} diff --git a/packages/language-server/src/scanner.ts b/packages/language-server/src/scanner.ts deleted file mode 100644 index 430842d2..00000000 --- a/packages/language-server/src/scanner.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { TextDocument } from "vscode-languageserver-textdocument"; -import { URI } from "vscode-uri"; -import { useContext } from "./context-provider"; -import type { ScssImport } from "./parser"; -import { parseDocument } from "./parser"; -import { getSCSSRegionsDocument } from "./utils/embedded"; - -export default class ScannerService { - public async scan(files: URI[], workspaceRoot: URI): Promise { - const { settings } = useContext(); - await Promise.all( - files.map((uri) => { - const path = uri.path; - if ( - settings.scanImportedFiles && - (path.includes("/_") || 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, workspaceRoot, 0); - }), - ); - } - - public async update( - document: TextDocument, - workspaceRoot: URI, - ): Promise { - const scssRegions = getSCSSRegionsDocument(document); - if (!scssRegions.document) { - return; - } - - const scssDocument = await parseDocument( - scssRegions.document, - workspaceRoot, - ); - const { storage } = useContext(); - storage.set(scssDocument.uri, scssDocument); - } - - protected async parse( - uri: URI, - workspaceRoot: URI, - depth: number, - ): Promise { - const { settings, storage, fs } = useContext(); - const isExistFile = await fs.exists(uri); - if (!isExistFile) { - storage.delete(uri); - return; - } - - const alreadyParsed = storage.has(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; - } - - try { - const content = await fs.readFile(uri); - const document = TextDocument.create(uri.toString(), "scss", 1, content); - const scssRegions = getSCSSRegionsDocument(document); - if (!scssRegions.document) { - return; - } - - const scssDocument = await parseDocument( - scssRegions.document, - workspaceRoot, - ); - storage.set(scssDocument.uri, scssDocument); - - const maxDepth = settings.scannerDepth ?? 30; - if (depth > maxDepth || !settings.scanImportedFiles) { - return; - } - - for (const symbol of scssDocument.getLinks()) { - if ( - !symbol.link.target || - (symbol as ScssImport).dynamic || - (symbol as ScssImport).css - ) { - continue; - } - - try { - await this.parse( - URI.parse(symbol.link.target), - workspaceRoot, - depth + 1, - ); - } catch (error) { - console.error((error as Error).message); - } - } - } catch (error) { - console.error((error as Error).message); - // Something went wrong parsing this file. Try to parse the others. - } - } -} diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index bc8d611d..969af4e4 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -1,3 +1,7 @@ +import { + getLanguageService, + LanguageService, +} from "@somesass/language-services"; import { ClientCapabilities, CodeAction, @@ -10,35 +14,16 @@ import { import { TextDocuments, TextDocumentSyncKind, -} from "vscode-languageserver/node"; -import type { - InitializeParams, - InitializeResult, + TextDocumentChangeEvent, } from "vscode-languageserver/node"; import { TextDocument } from "vscode-languageserver-textdocument"; import { URI } from "vscode-uri"; -import { - changeConfiguration, - createContext, - useContext, -} from "./context-provider"; -import { ExtractProvider } from "./features/code-actions"; -import { doCompletion } from "./features/completion"; -import { findDocumentColors } from "./features/decorators/color-decorators"; -import { doDiagnostics } from "./features/diagnostics/diagnostics"; -import { goDefinition } from "./features/go-definition/go-definition"; -import { doHover } from "./features/hover/hover"; -import { provideReferences } from "./features/references"; -import { doRename, prepareRename } from "./features/rename"; -import { doSignatureHelp } from "./features/signature-help/signature-help"; -import { searchWorkspaceSymbol } from "./features/workspace-symbols/workspace-symbol"; import type { FileSystemProvider } from "./file-system"; import { getFileSystemProvider } from "./file-system-provider"; import { RuntimeEnvironment } from "./runtime"; -import ScannerService from "./scanner"; -import { defaultSettings, IEditorSettings, type ISettings } from "./settings"; -import StorageService from "./storage"; +import { defaultSettings, IEditorSettings, ISettings } from "./settings"; import { getSCSSRegionsDocument } from "./utils/embedded"; +import WorkspaceScanner from "./workspace-scanner"; export class SomeSassServer { private readonly connection: Connection; @@ -50,10 +35,11 @@ export class SomeSassServer { } public listen(): void { - let workspaceRoot: URI; - let scannerService: ScannerService; - let fileSystemProvider: FileSystemProvider; - let clientCapabilities: ClientCapabilities; + let ls: LanguageService | undefined = undefined; + let workspaceRoot: URI | undefined = undefined; + let workspaceScanner: WorkspaceScanner | undefined = undefined; + let fileSystemProvider: FileSystemProvider | undefined = undefined; + let clientCapabilities: ClientCapabilities | undefined = undefined; // Create a simple text document manager. The text document manager // _supports full document sync only @@ -66,396 +52,405 @@ export class SomeSassServer { // After the server has started the client sends an initilize request. The server receives // _in the passed params the rootPath of the workspace plus the client capabilites - this.connection.onInitialize( - async (params: InitializeParams): Promise => { - clientCapabilities = params.capabilities; + this.connection.onInitialize((params) => { + this.connection.console.debug( + `[Server${process.pid ? `(${process.pid})` : ""} ${workspaceRoot}] received`, + ); - fileSystemProvider = getFileSystemProvider( - this.connection, - this.runtime, - ); + clientCapabilities = params.capabilities; - // TODO: migrate to workspace folders. Workspace was an unnecessary older workaround of mine. - workspaceRoot = URI.parse( - params.initializationOptions?.workspace || params.rootUri!, - ); + fileSystemProvider = getFileSystemProvider(this.connection, this.runtime); + + ls = getLanguageService({ + clientCapabilities, + fileSystemProvider, + languageModelCache: { + cleanupIntervalTimeInSeconds: 60, + }, + }); + // TODO: migrate to workspace folders. Workspace was an unnecessary older workaround of mine. + workspaceRoot = URI.parse( + params.initializationOptions?.workspace || params.rootUri!, + ); + + this.connection.console.debug( + `[Server${process.pid ? `(${process.pid})` : ""} ${workspaceRoot}] returning server capabilities`, + ); + + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + referencesProvider: true, + completionProvider: { + resolveProvider: false, + triggerCharacters: [ + // For SassDoc annotation completion + "@", + "/", + + // For @use completion + '"', + "'", + + // For placeholder completion + "%", + ], + }, + signatureHelpProvider: { + triggerCharacters: ["(", ",", ";"], + }, + hoverProvider: true, + definitionProvider: true, + documentHighlightProvider: true, + workspaceSymbolProvider: true, + codeActionProvider: { + codeActionKinds: [ + CodeActionKind.RefactorExtract, + CodeActionKind.RefactorExtract + ".function", + CodeActionKind.RefactorExtract + ".constant", + ], + resolveProvider: false, + }, + renameProvider: { prepareProvider: true }, + colorProvider: {}, + }, + }; + }); + + this.connection.onInitialized(async () => { + try { this.connection.console.debug( - `[Server(${process.pid}) ${workspaceRoot}] Initialize received`, + `[Server${process.pid ? `(${process.pid})` : ""} ${workspaceRoot}] received`, ); - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Incremental, - referencesProvider: true, - completionProvider: { - resolveProvider: false, - triggerCharacters: [ - // For SassDoc annotation completion - "@", - "/", - - // For @use completion - '"', - "'", - - // For placeholder completion - "%", - ], - }, - signatureHelpProvider: { - triggerCharacters: ["(", ",", ";"], - }, - hoverProvider: true, - definitionProvider: true, - workspaceSymbolProvider: true, - codeActionProvider: { - codeActionKinds: [ - CodeActionKind.RefactorExtract, - CodeActionKind.RefactorExtract + ".function", - CodeActionKind.RefactorExtract + ".constant", - ], - resolveProvider: false, - }, - renameProvider: { prepareProvider: true }, - colorProvider: {}, - }, + const somesassConfiguration: Partial = + await this.connection.workspace.getConfiguration("somesass"); + const editorConfiguration: Partial = + await this.connection.workspace.getConfiguration("editor"); + + const settings: ISettings = { + ...defaultSettings, + ...somesassConfiguration, }; - }, - ); - this.connection.onInitialized(async () => { - const somesassConfiguration: Partial = - await this.connection.workspace.getConfiguration("somesass"); - const editorConfiguration: Partial = - await this.connection.workspace.getConfiguration("editor"); - const storageService = new StorageService(); + const editorSettings: IEditorSettings = { + insertSpaces: false, + indentSize: undefined, + tabSize: 2, + ...editorConfiguration, + }; - const settings: ISettings = { - ...defaultSettings, - ...somesassConfiguration, - }; + if ( + !ls || + !clientCapabilities || + !workspaceRoot || + !fileSystemProvider + ) { + throw new Error( + "Got onInitialized without onInitialize readying up all required globals", + ); + } - const editorSettings: IEditorSettings = { - insertSpaces: false, - indentSize: undefined, - tabSize: 2, - ...editorConfiguration, - }; + ls.configure({ + editorSettings, + workspaceRoot, + }); - createContext({ - clientCapabilities, - fs: fileSystemProvider, - settings, - editorSettings, - workspaceRoot, - storage: storageService, - }); + workspaceScanner = new WorkspaceScanner(ls, fileSystemProvider, { + scannerDepth: settings.scannerDepth, + scanImportedFiles: settings.scanImportedFiles, + }); - scannerService = new ScannerService(); + this.connection.console.debug( + `[Server${process.pid ? `(${process.pid})` : ""} ${workspaceRoot}] scanning workspace for files`, + ); - const files = await fileSystemProvider.findFiles( - "**/*.{scss,svelte,astro,vue}", - settings.scannerExclude, - ); + const files = await fileSystemProvider.findFiles( + "**/*.{scss,svelte,astro,vue}", + settings.scannerExclude, + ); - try { - await scannerService.scan(files, workspaceRoot); + this.connection.console.debug( + `[Server${process.pid ? `(${process.pid})` : ""} ${workspaceRoot}] found ${files.length} files, starting parse`, + ); + + await workspaceScanner.scan(files); + + this.connection.console.debug( + `[Server${process.pid ? `(${process.pid})` : ""} ${workspaceRoot}] parsed ${files.length} files`, + ); } catch (error) { this.connection.console.log(String(error)); } }); - documents.onDidChangeContent(async (change) => { - if (!scannerService) { - return null; - } + const onDocumentChanged = async ( + params: TextDocumentChangeEvent, + ) => { + if (!workspaceScanner || !ls) return; try { - await scannerService.update(change.document, workspaceRoot); + ls.onDocumentChanged(params.document); } catch (error) { // Something went wrong trying to parse the changed document. this.connection.console.error((error as Error).message); return; } - const diagnostics = await doDiagnostics(change.document); - - // Check that no new version has been made while we waited - const latestTextDocument = documents.get(change.document.uri); - if ( - latestTextDocument && - latestTextDocument.version === change.document.version - ) { - this.connection.sendDiagnostics({ - uri: latestTextDocument.uri, - diagnostics, - }); - } - }); + const diagnostics = await ls.doDiagnostics(params.document); + + // Check that no new version has been made while we waited, + // in which case the diagnostics may no longer be valid. + const latest = documents.get(params.document.uri); + if (!latest || latest.version !== params.document.version) return; + + this.connection.sendDiagnostics({ + uri: latest.uri, + diagnostics, + }); + }; + + documents.onDidOpen(onDocumentChanged); + documents.onDidChangeContent(onDocumentChanged); this.connection.onDidChangeConfiguration((params) => { - const settings: ISettings = params.settings.somesass; - changeConfiguration(settings); + if (!ls) return; + + const editorConfiguration: Partial = + params.settings.editor; + + const editorSettings: IEditorSettings = { + insertSpaces: false, + indentSize: undefined, + tabSize: 2, + ...editorConfiguration, + }; + + ls.configure({ + editorSettings, + workspaceRoot, + }); }); this.connection.onDidChangeWatchedFiles(async (event) => { - if (!scannerService) { - return null; - } + if (!workspaceScanner || !fileSystemProvider || !ls) return; - const context = useContext(); - if (!context) { - return; - } - - const { storage } = context; const newFiles: URI[] = []; for (const change of event.changes) { - const uri = URI.parse(change.uri); + const uri = await fileSystemProvider.realPath(URI.parse(change.uri)); + if (change.type === FileChangeType.Deleted) { - storage.delete(uri); + ls.onDocumentRemoved(uri.toString()); } else if (change.type === FileChangeType.Changed) { - const document = storage.get(uri); - if (document) { - await scannerService.update(document, workspaceRoot); - } else { + const document = documents.get(uri.toString()); + if (!document) { // New to us anyway newFiles.push(uri); + } else { + ls.onDocumentChanged(document); } } else { newFiles.push(uri); } } - return scannerService.scan(newFiles, workspaceRoot); + + await workspaceScanner.scan(newFiles); }); - this.connection.onCompletion(async (textDocumentPosition) => { - const uri = documents.get(textDocumentPosition.textDocument.uri); - if (uri === undefined) { - return; - } + this.connection.onCompletion(async (params) => { + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument( - uri, - textDocumentPosition.position, + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, ); - if (!document) { - return null; - } + if (!document) return null; - const completions = await doCompletion(document, offset); - - return completions; + const result = await ls.doComplete(document, params.position); + return result; }); - this.connection.onHover((textDocumentPosition) => { - const uri = documents.get(textDocumentPosition.textDocument.uri); - if (uri === undefined) { - return; - } + this.connection.onHover((params) => { + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument( - uri, - textDocumentPosition.position, + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, ); - if (!document) { - return null; - } + if (!document) return null; - return doHover(document, offset); + const result = ls.doHover(document, params.position); + return result; }); - this.connection.onSignatureHelp((textDocumentPosition) => { - const uri = documents.get(textDocumentPosition.textDocument.uri); - if (uri === undefined) { - return; - } + this.connection.onSignatureHelp(async (params) => { + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument( - uri, - textDocumentPosition.position, + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, ); - if (!document) { - return null; - } + if (!document) return null; - return doSignatureHelp(document, offset); + const result = await ls.doSignatureHelp(document, params.position); + return result; }); - this.connection.onDefinition((textDocumentPosition) => { - const uri = documents.get(textDocumentPosition.textDocument.uri); - if (uri === undefined) { - return; - } + this.connection.onDefinition((params) => { + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument( - uri, - textDocumentPosition.position, + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, ); - if (!document) { - return null; - } + if (!document) return null; - return goDefinition(document, offset); + const result = ls.findDefinition(document, params.position); + return result; }); - this.connection.onReferences(async (referenceParams) => { - const uri = documents.get(referenceParams.textDocument.uri); - if (uri === undefined) { - return undefined; - } + this.connection.onDocumentHighlight((params) => { + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument( - uri, - referenceParams.position, + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, ); - if (!document) { - return null; - } + if (!document) return null; - const options = referenceParams.context; - const references = await provideReferences(document, offset, options); + const result = ls.findDocumentHighlights(document, params.position); + return result; + }); - if (!references) { - return null; - } + this.connection.onReferences(async (params) => { + if (!ls) return null; - return references.references.map((r) => r.location); - }); + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, + ); + if (!document) return null; - this.connection.onWorkspaceSymbol((workspaceSymbolParams) => { - return searchWorkspaceSymbol( - workspaceSymbolParams.query, - workspaceRoot.toString(), + const references = await ls.findReferences( + document, + params.position, + params.context, ); + return references; + }); + + this.connection.onWorkspaceSymbol((params) => { + if (!ls) return null; + + const result = ls.findWorkspaceSymbols(params.query); + return result; }); this.connection.onCodeAction(async (params) => { - const context = useContext(); - if (!context) { - return; - } + if (!ls) return null; - const { editorSettings } = context; - const codeActionProviders = [new ExtractProvider(editorSettings)]; + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + ); + if (!document) return null; - const document = documents.get(params.textDocument.uri); - if (document === undefined) { - return undefined; - } + const result: (Command | CodeAction)[] = []; - const allActions: (Command | CodeAction)[] = []; + const actions = await ls.getCodeActions( + document, + params.range, + params.context, + ); - for (const provider of codeActionProviders) { - const actions = await provider.provideCodeActions( - document, - params.range, - ); + for (const action of actions) { + if (action.kind?.startsWith("refactor.extract")) { + // Replace with a custom command that immediately starts a rename after applying the edit. + // If this causes problems for other clients, look into passing some kind of client identifier (optional) + // with initOptions that indicate this command exists in the client. - if (provider instanceof ExtractProvider) { - for (const action of actions) { - const edit: TextDocumentEdit | undefined = action.edit - ?.documentChanges?.[0] as TextDocumentEdit; - - const command = Command.create( - action.title, - "_somesass.applyExtractCodeAction", - document.uri, - document.version, - edit && edit.edits[0], - ); - - allActions.push( - CodeAction.create(action.title, command, action.kind), - ); - } + const edit: TextDocumentEdit | undefined = action.edit + ?.documentChanges?.[0] as TextDocumentEdit; + + const command = Command.create( + action.title, + "_somesass.applyExtractCodeAction", + document.uri, + document.version, + edit && edit.edits[0], + ); + + result.push(CodeAction.create(action.title, command, action.kind)); + } else { + result.push(action); } } - return allActions; + return result; }); this.connection.onPrepareRename(async (params) => { - const uri = documents.get(params.textDocument.uri); - if (uri === undefined) { - return null; - } + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument(uri, params.position); - if (!document) { - return null; - } + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, + ); + if (!document) return null; - const preparations = await prepareRename(document, offset); + const preparations = await ls.prepareRename(document, params.position); return preparations; }); this.connection.onRenameRequest(async (params) => { - const uri = documents.get(params.textDocument.uri); - if (uri === undefined) { - return null; - } + if (!ls) return null; - const { document, offset } = getSCSSRegionsDocument(uri, params.position); - if (!document) { - return null; - } + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + params.position, + ); + if (!document) return null; - const edits = await doRename(document, offset, params.newName); + const edits = await ls.doRename( + document, + params.position, + params.newName, + ); return edits; }); this.connection.onDocumentColor(async (params) => { - const uri = documents.get(params.textDocument.uri); - if (uri === undefined) { - return null; - } - - const { document } = getSCSSRegionsDocument(uri); - if (!document) { - return null; - } - - const context = useContext(); - if (!context) { - return null; - } + if (!ls) return null; - const { storage } = context; - const scssDocument = storage.get(document.uri); - if (!scssDocument) { - // For the first open document, we may have a race condition where the scanner - // hasn't finished before the documentColor request is sent from the client. - // In these cases, initiate a scan for the document and wait for it to finish, - // to ensure we get color decorators without having to edit the file first. - await scannerService.scan([URI.parse(document.uri)], workspaceRoot); - } + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + ); + if (!document) return null; - const information = findDocumentColors(document); + const information = await ls.findColors(document); return information; }); - this.connection.onColorPresentation(() => { - // const uri = documents.get(params.textDocument.uri); - // if (uri === undefined) { - // return null; - // } - // const { document } = getSCSSRegionsDocument(uri); - // if (!document) { - // return null; - // } - // const presentations = getColorPresentations(document, params.color, params.range); - // return presentations; - - return []; // Don't replace the variable reference with raw color values... + this.connection.onColorPresentation((params) => { + if (!ls) return null; + + const document = getSCSSRegionsDocument( + documents.get(params.textDocument.uri), + ); + if (!document) return null; + + const result = ls.getColorPresentations( + document, + params.color, + params.range, + ); + return result; }); this.connection.onShutdown(() => { - const context = useContext(); - if (context) { - context.storage.clear(); - } + if (!ls) return; + + ls.clearCache(); }); this.connection.listen(); diff --git a/packages/language-server/src/storage.ts b/packages/language-server/src/storage.ts deleted file mode 100644 index 14e31cc7..00000000 --- a/packages/language-server/src/storage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { URI } from "vscode-uri"; -import type { IScssDocument } from "./parser"; - -export type Storage = Map; -export type StorageItemEntry = [StorageItemKey, StorageItemValue]; - -export type StorageItemKey = string; -export type StorageItemValue = IScssDocument; - -export default class StorageService { - private readonly storage: Storage = new Map(); - - public get(key: StorageItemKey | URI): StorageItemValue | undefined { - return this.storage.get(this.toKey(key)); - } - - public has(key: StorageItemKey | URI): boolean { - return this.storage.has(this.toKey(key)); - } - - public set(key: StorageItemKey | URI, value: StorageItemValue): void { - this.storage.set(this.toKey(key), value); - } - - public delete(key: StorageItemKey | URI): void { - this.storage.delete(this.toKey(key)); - } - - public clear(): void { - this.storage.clear(); - } - - public keys(): IterableIterator { - return this.storage.keys(); - } - - public values(): IterableIterator { - return this.storage.values(); - } - - public entries(): IterableIterator { - return this.storage.entries(); - } - - private toKey(key: StorageItemKey | URI): StorageItemKey { - return key.toString(); - } -} diff --git a/packages/language-server/test/utils/embedded.spec.ts b/packages/language-server/src/utils/__tests__/embedded.test.ts similarity index 64% rename from packages/language-server/test/utils/embedded.spec.ts rename to packages/language-server/src/utils/__tests__/embedded.test.ts index d4b46402..523792af 100644 --- a/packages/language-server/test/utils/embedded.spec.ts +++ b/packages/language-server/src/utils/__tests__/embedded.test.ts @@ -1,4 +1,4 @@ -import { strictEqual, deepStrictEqual, notDeepStrictEqual } from "assert"; +import { assert, describe, it } from "vitest"; import { Position } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import { @@ -6,93 +6,111 @@ import { getSCSSRegions, getSCSSContent, getSCSSRegionsDocument, -} from "../../src/utils/embedded"; +} from "../embedded"; describe("Utils/VueSvelte", () => { it("isFileWhereScssCanBeEmbedded", () => { - strictEqual(isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.vue"), true); - strictEqual( + assert.strictEqual( + isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.vue"), + true, + ); + assert.strictEqual( isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.scss.vue"), true, ); - strictEqual(isFileWhereScssCanBeEmbedded("sda.vue/AppButton.scss"), false); - strictEqual( + assert.strictEqual( + isFileWhereScssCanBeEmbedded("sda.vue/AppButton.scss"), + false, + ); + assert.strictEqual( isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.vue.scss"), false, ); - strictEqual(isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.svelte"), true); - strictEqual( + assert.strictEqual( + isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.svelte"), + true, + ); + assert.strictEqual( isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.scss.svelte"), true, ); - strictEqual( + assert.strictEqual( isFileWhereScssCanBeEmbedded("sda.svelte/AppButton.scss"), false, ); - strictEqual( + assert.strictEqual( isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.svelte.scss"), false, ); - strictEqual(isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.astro"), true); - strictEqual( + assert.strictEqual( + isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.astro"), + true, + ); + assert.strictEqual( isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.scss.astro"), true, ); - strictEqual( + assert.strictEqual( isFileWhereScssCanBeEmbedded("sda.astro/AppButton.scss"), false, ); - strictEqual( + assert.strictEqual( isFileWhereScssCanBeEmbedded("sdasdsa/AppButton.astro.scss"), false, ); }); it("getSCSSRegions", () => { - deepStrictEqual( + assert.deepStrictEqual( getSCSSRegions(''), [[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..5da832dc --- /dev/null +++ b/packages/language-services/src/features/do-complete.ts @@ -0,0 +1,1501 @@ +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); + if (moduleName && moduleName !== "." && moduleName !== "..") { + const rootFolderUri = this.configuration.workspaceRoot + ? Utils.joinPath(this.configuration.workspaceRoot, "/").toString(true) + : ""; + const documentFolderUri = Utils.dirname(URI.parse(document.uri)).toString( + true, + ); + + 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", + }, + }); + } + } + } + } + + 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; + } + + 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..ff70e173 --- /dev/null +++ b/packages/language-services/src/utils/fs-provider.ts @@ -0,0 +1,34 @@ +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; + }, + }; + 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..b27973a9 --- /dev/null +++ b/packages/vscode-css-languageservice/src/cssLanguageTypes.ts @@ -0,0 +1,404 @@ +/*--------------------------------------------------------------------------------------------- + * 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][]>; +} + +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: "