diff --git a/README.md b/README.md index 8740d31..e645398 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,11 @@ # Overview -This project is an early proof of concept of a language server for Sass written in Dart to make use of [sass_api](https://pub.dev/packages/sass_api). The proof of concept includes: +This is a work-in-progress language server for Sass written in Dart to +use [sass_api](https://pub.dev/packages/sass_api). -- Language server process with incremental document sync. -- User configuration. -- [Document links](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentLink) provider. -- Workspace scanner. -- VS Code client with debugging profile. - -Check the documentation to get started: +See the [initial version roadmap](https://github.com/sass/dart-sass-language-server/issues/2) for the state of different features or check the documentation to get started: - [Development environment](./docs/contributing/development-environment.md) -- [Debugging](./docs/contributing/debugging.md) +- [Testing and debugging](./docs/contributing/testing-and-debugging.md) - [Building](./docs/contributing/building.md) - [Architecture](./docs/contributing/architecture.md) diff --git a/docs/contributing/development-environment.md b/docs/contributing/development-environment.md index 8c15648..3c3aed1 100644 --- a/docs/contributing/development-environment.md +++ b/docs/contributing/development-environment.md @@ -1,6 +1,8 @@ ## Development environment -The Sass language server, like the Sass compiler, is written in [Dart](https://dart.dev/). The language extension for Visual Studio Code is written in [TypeScript](https://www.typescriptlang.org/). +The Sass language server, like the Sass compiler, is written in [Dart](https://dart.dev/). +The language extension for Visual Studio Code is written in [TypeScript](https://www.typescriptlang.org/). +Tests for the extension are written in JavaScript. If you have a background writing JavaScript or TypeScript, [Learning Dart as a JavaScript developer](https://dart.dev/resources/coming-from/js-to-dart) is a great place to start. diff --git a/docs/contributing/debugging.md b/docs/contributing/testing-and-debugging.md similarity index 98% rename from docs/contributing/debugging.md rename to docs/contributing/testing-and-debugging.md index 9ee4260..00b7930 100644 --- a/docs/contributing/debugging.md +++ b/docs/contributing/testing-and-debugging.md @@ -1,4 +1,4 @@ -# Debugging +# Testing and debugging Here we assume you have set up a [development environment](./development-environment.md). @@ -13,6 +13,16 @@ The quickest way to test the language server is to debug the language extension This will open another window of Visual Studio Code, this one running as an `[Extension Development Host]`. +### Testing in isolation + +VS Code ships with some built-in support for SCSS and CSS. To test this language server in isolation you can disable the built-in extension. + +1. Go to the Extensions tab and search for `@builtin css language features`. +2. Click the settings icon and pick Disable from the list. +3. Click Restart extension to turn it off. + +You should also turn off extensions like SCSS IntelliSense or Some Sass. + ### Open the Dart DevTools In this configuration, the client has run `dart run --observe` in the local `sass_language_server` package. You can now use [Dart DevTools](https://dart.dev/tools/dart-devtools) to debug the language server. @@ -61,13 +71,3 @@ test profile in the Run and Debug view in VS Code. ] } ``` - -## Testing in isolation - -VS Code ships with some built-in support for SCSS and CSS. To test this language server in isolation you can disable the built-in extension. - -1. Go to the Extensions tab and search for `@builtin css language features`. -2. Click the settings icon and pick Disable from the list. -3. Click Restart extension to turn it off. - -You should also turn off extensions like SCSS IntelliSense or Some Sass. diff --git a/extension/package.json b/extension/package.json index e1998c2..5b362a3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -92,10 +92,10 @@ "type": "string" }, "default": [ - "**/.git/**", - "**/node_modules/**" + ".git/**", + "node_modules/**" ], - "description": "List of glob patterns for directories that are excluded when scanning.", + "description": "List of glob patterns for files that are excluded when scanning.", "order": 1 }, "sass.workspace.logLevel": { @@ -213,6 +213,13 @@ "default": true, "description": "Enable or disable all diagnostics." }, + "sass.scss.documentSymbols.enabled": { + "order": 55, + "type": "boolean", + "scope": "resource", + "default": true, + "description": "Enable or disable Document symbols." + }, "sass.scss.foldingRanges.enabled": { "order": 60, "type": "boolean", @@ -248,7 +255,7 @@ "default": true, "description": "Show references to Sass documentation for Sass built-in modules and SassDoc for annotations." }, - "sass.scss.links.enabled": { + "sass.scss.documentLinks.enabled": { "order": 90, "type": "boolean", "scope": "resource", @@ -283,7 +290,7 @@ "default": true, "description": "Enable or disable signature help." }, - "sass.scss.workspaceSymbol.enabled": { + "sass.scss.workspaceSymbols.enabled": { "order": 140, "type": "boolean", "scope": "resource", @@ -371,6 +378,13 @@ "default": true, "description": "Enable or disable all diagnostics." }, + "sass.sass.documentSymbols.enabled": { + "order": 55, + "type": "boolean", + "scope": "resource", + "default": true, + "description": "Enable or disable Document symbols." + }, "sass.sass.foldingRanges.enabled": { "order": 60, "type": "boolean", @@ -406,7 +420,7 @@ "default": true, "description": "Show references to MDN in CSS hovers, Sass documentation for Sass built-in modules and SassDoc for annotations." }, - "sass.sass.links.enabled": { + "sass.sass.documentLinks.enabled": { "order": 90, "type": "boolean", "scope": "resource", @@ -439,14 +453,14 @@ "type": "boolean", "scope": "resource", "default": true, - "description": "Enable or disable selection ranges." + "description": "Enable or disable signature help." }, - "sass.sass.workspaceSymbol.enabled": { + "sass.sass.workspaceSymbols.enabled": { "order": 140, "type": "boolean", "scope": "resource", "default": true, - "description": "Enable or disable selection ranges." + "description": "Enable or disable workspace symbols." } } }, @@ -502,6 +516,13 @@ "default": false, "description": "Enable or disable all diagnostics." }, + "sass.css.documentSymbols.enabled": { + "order": 55, + "type": "boolean", + "scope": "resource", + "default": true, + "description": "Enable or disable Document symbols." + }, "sass.css.foldingRanges.enabled": { "order": 60, "type": "boolean", @@ -537,7 +558,7 @@ "default": true, "description": "Show references to MDN in CSS hovers." }, - "sass.css.links.enabled": { + "sass.css.documentLinks.enabled": { "order": 90, "type": "boolean", "scope": "resource", @@ -570,14 +591,14 @@ "type": "boolean", "scope": "resource", "default": false, - "description": "Enable or disable selection ranges." + "description": "Enable or disable signature help." }, - "sass.css.workspaceSymbol.enabled": { + "sass.css.workspaceSymbols.enabled": { "order": 140, "type": "boolean", "scope": "resource", "default": false, - "description": "Enable or disable selection ranges." + "description": "Enable or disable workspace symbols." } } } diff --git a/extension/src/main.ts b/extension/src/main.ts index 6e82233..af5a287 100644 --- a/extension/src/main.ts +++ b/extension/src/main.ts @@ -132,6 +132,58 @@ export async function activate(context: ExtensionContext): Promise { } } }); + + // TODO: Maybe worth looking into so links to built-ins resolve to something? + // workspace.registerFileSystemProvider( + // 'sass', + // { + // readFile(uri) { + // return Uint8Array.from( + // '@function hello();'.split('').map((c) => c.charCodeAt(0)) + // ); + // }, + // watch(uri, options) { + // return Disposable.create(() => { + // console.log('hello'); + // }); + // }, + // readDirectory(uri) { + // return []; + // }, + // stat(uri) { + // return { + // ctime: 0, + // mtime: 0, + // size: 0, + // type: 1, + // }; + // }, + // writeFile(uri, content, options) { + // return; + // }, + // createDirectory(uri) { + // return; + // }, + // delete(uri, options) { + // return; + // }, + // rename(oldUri, newUri, options) { + // return; + // }, + // copy(source, destination, options) { + // return; + // }, + // onDidChangeFile(e) { + // return Disposable.create(() => { + // console.log('hello'); + // }); + // }, + // }, + // { + // isCaseSensitive: false, + // isReadonly: true, + // } + // ); } export async function deactivate(): Promise { diff --git a/extension/test/README.md b/extension/test/README.md new file mode 100644 index 0000000..78dfd95 --- /dev/null +++ b/extension/test/README.md @@ -0,0 +1,15 @@ +# Testing the VS Code extension + +These tests automate Visual Studio Code using the [VS Code JavaScript API](https://code.visualstudio.com/api/references/vscode-api). +See [the list of built-in commands](https://code.visualstudio.com/api/references/commands#commands) to see how you can automate the interactions you need for testing. + +The runner is configured so it can run multiple instances of VS Code (in sequence) using different configurations and workspaces. +Each subdirectory in `electron/` will run in a separate instance of VS Code. + +By convention subdirectories in `electron/` should have: + +- `index.js` as the entrypoint that finds test files and passes them to Mocha +- at least one `*.test.js` file with some tests +- a `fixtures/` subdirectory with: + - `.vscode/settings.json` + - `styles.scss` or `styles.sass` diff --git a/extension/test/electron/document-symbols/document-symbols.test.js b/extension/test/electron/document-symbols/document-symbols.test.js new file mode 100644 index 0000000..f324d52 --- /dev/null +++ b/extension/test/electron/document-symbols/document-symbols.test.js @@ -0,0 +1,38 @@ +const assert = require('node:assert'); +const path = require('node:path'); +const vscode = require('vscode'); +const { showFile, sleepCI } = require('../util'); + +const stylesUri = vscode.Uri.file( + path.resolve(__dirname, 'fixtures', 'styles.sass') +); + +before(async () => { + await showFile(stylesUri); + await sleepCI(); +}); + +after(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); +}); + +/** + * @param {import('vscode').Uri} documentUri + * @returns {Promise} + */ +async function findDocumentSymbols(documentUri) { + const result = await vscode.commands.executeCommand( + 'vscode.executeDocumentSymbolProvider', + documentUri + ); + return result; +} + +test('gets CSS selectors', async () => { + const result = await findDocumentSymbols(stylesUri); + + assert.ok( + result.find((s) => s.name === '.card .body:has(:not(.stuff))'), + 'Should have found .card .body:has(:not(.stuff))' + ); +}); diff --git a/extension/test/electron/document-symbols/fixtures/styles.sass b/extension/test/electron/document-symbols/fixtures/styles.sass new file mode 100644 index 0000000..28b152f --- /dev/null +++ b/extension/test/electron/document-symbols/fixtures/styles.sass @@ -0,0 +1,17 @@ +button + border-color: red + +.hello + color: green + +.world + color: blue + +.card > .header, +.card > .body, +.card > .footer + padding: 2px + + +.card .body:has(:not(.stuff)) + padding: 4px diff --git a/extension/test/electron/document-symbols/index.js b/extension/test/electron/document-symbols/index.js new file mode 100644 index 0000000..83212dd --- /dev/null +++ b/extension/test/electron/document-symbols/index.js @@ -0,0 +1,25 @@ +const path = require('node:path'); +const fs = require('node:fs/promises'); +const vscode = require('vscode'); +const { runMocha } = require('../mocha'); + +/** + * @returns {Promise} + */ +async function run() { + const filePaths = []; + + const dir = await fs.readdir(__dirname, { withFileTypes: true }); + for (let entry of dir) { + if (entry.isFile() && entry.name.endsWith('test.js')) { + filePaths.push(path.join(entry.parentPath, entry.name)); + } + } + + await runMocha( + filePaths, + vscode.Uri.file(path.resolve(__dirname, 'fixtures', 'styles.sass')) + ); +} + +module.exports = { run }; diff --git a/pkgs/sass_language_server/lib/src/language_server.dart b/pkgs/sass_language_server/lib/src/language_server.dart index 1cf0210..3a7dde5 100644 --- a/pkgs/sass_language_server/lib/src/language_server.dart +++ b/pkgs/sass_language_server/lib/src/language_server.dart @@ -122,10 +122,13 @@ class LanguageServer { var links = await _ls.findDocumentLinks(document); for (var link in links) { if (link.target == null) continue; - if (link.target!.path.contains('#{')) continue; + + var target = link.target.toString(); + if (target.contains('#{')) continue; // Our findFiles glob will handle the initial parsing of CSS files - if (link.target!.path.endsWith('.css')) continue; - if (link.target!.path.startsWith('sass:')) continue; + if (target.endsWith('.css')) continue; + // Sass built-ins are not files we can scan. + if (target.startsWith('sass:')) continue; var visited = _ls.cache.getDocument(link.target as Uri); if (visited != null) { @@ -168,6 +171,7 @@ class LanguageServer { var serverCapabilities = ServerCapabilities( documentLinkProvider: DocumentLinkOptions(resolveProvider: false), + documentSymbolProvider: Either2.t1(true), textDocumentSync: Either2.t1(TextDocumentSyncKind.Incremental), ); @@ -207,8 +211,7 @@ class LanguageServer { } _log.debug('Searching workspace for files'); - var files = await fileSystemProvider.findFiles( - '**/*.{css,scss,sass}', + var files = await fileSystemProvider.findFiles('**.{css,scss,sass}', root: _workspaceRoot.toFilePath(), exclude: _ls.configuration.workspace.exclude); _log.debug('Found ${files.length} files in workspace'); @@ -231,14 +234,13 @@ class LanguageServer { } }); - // The spec says we can return null here which I'd prefer to the empty list _connection.onDocumentLinks((params) async { try { var document = _documents.get(params.textDocument.uri); if (document == null) return []; var configuration = _getLanguageConfiguration(document); - if (configuration.links.enabled) { + if (configuration.documentLinks.enabled) { if (initialScan != null) { await initialScan; } @@ -254,6 +256,35 @@ class LanguageServer { } }); + // TODO: upstream allowing DocumentSymbol here + Future> onDocumentSymbol(dynamic params) async { + try { + var documentSymbolParams = DocumentSymbolParams.fromJson( + params.value as Map); + + var document = _documents.get(documentSymbolParams.textDocument.uri); + if (document == null) return []; + + var configuration = _getLanguageConfiguration(document); + if (configuration.documentSymbols.enabled) { + if (initialScan != null) { + await initialScan; + } + + var result = _ls.findDocumentSymbols(document); + return Future.value(result); + } else { + return []; + } + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } + } + + _connection.peer + .registerMethod('textDocument/documentSymbol', onDocumentSymbol); + _connection.onShutdown(() async { await _socket?.close(); exitCode = 0; @@ -264,7 +295,8 @@ class LanguageServer { }); _connection.listen(); - } on SocketException catch (_) { + } on Exception catch (e) { + _log.error(e.toString()); exit(exitCode); } } diff --git a/pkgs/sass_language_server/lib/src/local_file_system.dart b/pkgs/sass_language_server/lib/src/local_file_system.dart index f4c7198..d50601c 100644 --- a/pkgs/sass_language_server/lib/src/local_file_system.dart +++ b/pkgs/sass_language_server/lib/src/local_file_system.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:glob/glob.dart'; import 'package:glob/list_local_fs.dart'; +import 'package:path/path.dart' as p; import 'package:sass_language_server/src/utils/uri.dart'; import 'package:sass_language_services/sass_language_services.dart'; @@ -9,23 +10,36 @@ class LocalFileSystem extends FileSystemProvider { @override Future> findFiles(String pattern, {String? root, List? exclude}) async { - var list = - await Glob(pattern, caseSensitive: false).list(root: root).toList(); + var list = await Glob(pattern, caseSensitive: false) + .list( + root: root, + // Skip links. We resolve the real path behind links in findDocumentLinks later on. + // If we follow links here we can end up with an inflated initial list in + // monorepos that use symlinks in node_modules. + followLinks: false, + ) + .toList(); var excludeGlobs = []; if (exclude != null) { for (var pattern in exclude) { - excludeGlobs.add(Glob(pattern)); + excludeGlobs.add(Glob(pattern, + caseSensitive: false, context: p.Context(style: p.Style.url))); } } var result = []; for (var match in list) { + var excluded = false; for (var glob in excludeGlobs) { - if (glob.matches(match.path)) { - continue; + if (glob.matches(match.uri.path)) { + excluded = true; + break; } } + if (excluded) { + continue; + } result.add(match.uri); } diff --git a/pkgs/sass_language_server/pubspec.yaml b/pkgs/sass_language_server/pubspec.yaml index 97e89e9..23572fc 100644 --- a/pkgs/sass_language_server/pubspec.yaml +++ b/pkgs/sass_language_server/pubspec.yaml @@ -13,6 +13,7 @@ environment: dependencies: lsp_server: ^0.3.2 glob: ^2.1.2 + path: ^1.9.0 sass_language_services: ^1.0.0 dev_dependencies: diff --git a/pkgs/sass_language_server/test/local_file_system_test.dart b/pkgs/sass_language_server/test/local_file_system_test.dart new file mode 100644 index 0000000..4400a3f --- /dev/null +++ b/pkgs/sass_language_server/test/local_file_system_test.dart @@ -0,0 +1,45 @@ +import 'package:glob/glob.dart'; +import 'package:path/path.dart' as p; +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:test/test.dart'; + +void main() { + group('Glob', () { + test('default exclude patterns match paths as expected', () { + var config = WorkspaceConfiguration.from(null); + var excludeGlobs = []; + for (var pattern in config.exclude) { + excludeGlobs.add(Glob(pattern, + caseSensitive: false, context: p.Context(style: p.Style.url))); + } + + var nodeModulesPath = + '/home/user/workspace/project/node_modules/dependency/styles/index.scss'; + + var nodeModulesGlob = excludeGlobs.last; + + var matches = nodeModulesGlob.matches(nodeModulesPath); + expect(matches, isTrue); + }); + + test('user provided exclude patterns match paths as expected', () { + var config = WorkspaceConfiguration.from({ + 'exclude': ['node_modules/**'] + }); + + var excludeGlobs = []; + for (var pattern in config.exclude) { + excludeGlobs.add(Glob(pattern, + caseSensitive: false, context: p.Context(style: p.Style.url))); + } + + var nodeModulesPath = + '/home/user/workspace/project/node_modules/dependency/styles/index.scss'; + + var nodeModulesGlob = excludeGlobs.first; + + var matches = nodeModulesGlob.matches(nodeModulesPath); + expect(matches, isTrue); + }); + }); +} diff --git a/pkgs/sass_language_services/lib/sass_language_services.dart b/pkgs/sass_language_services/lib/sass_language_services.dart index 9c0d854..6645e70 100644 --- a/pkgs/sass_language_services/lib/sass_language_services.dart +++ b/pkgs/sass_language_services/lib/sass_language_services.dart @@ -21,3 +21,4 @@ export 'src/file_system_provider.dart' export 'src/language_services.dart' show LanguageServices; export 'src/features/document_links/stylesheet_document_link.dart'; +export 'src/features/document_symbols/stylesheet_document_symbol.dart'; diff --git a/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart b/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart index 8870511..b3f6116 100644 --- a/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart +++ b/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart @@ -4,22 +4,22 @@ class FeatureConfiguration { FeatureConfiguration({required this.enabled}); } -class DefinitionConfiguration extends FeatureConfiguration { - DefinitionConfiguration({required super.enabled}); +class DocumentSymbolsConfiguration extends FeatureConfiguration { + DocumentSymbolsConfiguration({required super.enabled}); } -class LinksConfiguration extends FeatureConfiguration { - LinksConfiguration({required super.enabled}); +class DocumentLinksConfiguration extends FeatureConfiguration { + DocumentLinksConfiguration({required super.enabled}); } class LanguageConfiguration { - late final DefinitionConfiguration definition; - late final LinksConfiguration links; + late final DocumentSymbolsConfiguration documentSymbols; + late final DocumentLinksConfiguration documentLinks; LanguageConfiguration.from(dynamic config) { - definition = DefinitionConfiguration( - enabled: config?['definition']?['enabled'] as bool? ?? true); - links = LinksConfiguration( - enabled: config?['links']?['enabled'] as bool? ?? true); + documentSymbols = DocumentSymbolsConfiguration( + enabled: config?['documentSymbols']?['enabled'] as bool? ?? true); + documentLinks = DocumentLinksConfiguration( + enabled: config?['documentLinks']?['enabled'] as bool? ?? true); } } diff --git a/pkgs/sass_language_services/lib/src/configuration/workspace_configuration.dart b/pkgs/sass_language_services/lib/src/configuration/workspace_configuration.dart index 636f30b..26f56cd 100644 --- a/pkgs/sass_language_services/lib/src/configuration/workspace_configuration.dart +++ b/pkgs/sass_language_services/lib/src/configuration/workspace_configuration.dart @@ -1,6 +1,6 @@ class WorkspaceConfiguration { /// Exclude paths from the initial workspace scan. Defaults include `.git` and `node_modules`. - final List exclude = ['**/.git/**', '**/node_modules/**']; + final List exclude = ['/**/.git/**', '/**/node_modules/**']; final Map importAliases = {}; /// Pass in [load paths](https://sass-lang.com/documentation/cli/dart-sass/#load-path) that will be used in addition to `node_modules`. @@ -15,7 +15,12 @@ class WorkspaceConfiguration { exclude.removeRange(0, exclude.length); for (var entry in excludeConfig) { if (entry is String) { - exclude.add(entry); + if (entry.startsWith('/')) { + exclude.add(entry); + } else { + // Paths we match against using Glob are absolute. + exclude.add('/**/$entry'); + } } } } diff --git a/pkgs/sass_language_services/lib/src/features/document_links/document_links_feature.dart b/pkgs/sass_language_services/lib/src/features/document_links/document_links_feature.dart index 460be98..657736c 100644 --- a/pkgs/sass_language_services/lib/src/features/document_links/document_links_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/document_links/document_links_feature.dart @@ -41,10 +41,9 @@ class DocumentLinksFeature extends LanguageFeature { } if (target.startsWith('sass:')) { - // target is not included since this doesn't link to a file on disk - // TODO: https://github.com/sass/dart-sass-language-server/issues/5#issuecomment-2452932807 resolvedLinks.add(StylesheetDocumentLink( type: link.type, + target: Uri.parse('sass:///${link.namespace}.scss'), range: link.range, data: link.data, tooltip: link.tooltip, @@ -54,6 +53,7 @@ class DocumentLinksFeature extends LanguageFeature { shownVariables: link.shownVariables, hiddenMixinsAndFunctions: link.hiddenMixinsAndFunctions, shownMixinsAndFunctions: link.shownMixinsAndFunctions)); + resolvedLinks.add(link); continue; } @@ -234,8 +234,12 @@ class DocumentLinksFeature extends LanguageFeature { final modulePath = await _resolvePathToModule(moduleName, documentFolder, rootFolder); if (modulePath != null) { - final pathWithinModule = target.path.substring(moduleName.length + 1); - return joinPath(modulePath, [pathWithinModule]); + if (target.path.length > moduleName.length + 1) { + final pathWithinModule = target.path.substring(moduleName.length + 1); + return joinPath(modulePath, [pathWithinModule]); + } else { + return modulePath; + } } return null; diff --git a/pkgs/sass_language_services/lib/src/features/document_symbols/document_symbols_feature.dart b/pkgs/sass_language_services/lib/src/features/document_symbols/document_symbols_feature.dart new file mode 100644 index 0000000..b2ab6a9 --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/document_symbols/document_symbols_feature.dart @@ -0,0 +1,24 @@ +import 'package:sass_language_services/sass_language_services.dart'; + +import '../language_feature.dart'; +import 'document_symbols_visitor.dart'; + +class DocumentSymbolsFeature extends LanguageFeature { + DocumentSymbolsFeature({required super.ls}); + + List findDocumentSymbols(TextDocument document) { + final cached = ls.cache.getDocumentSymbols(document); + if (cached != null) { + return cached; + } + + var stylesheet = ls.parseStylesheet(document); + + var symbolsVisitor = DocumentSymbolsVisitor(); + stylesheet.accept(symbolsVisitor); + + ls.cache.setDocumentSymbols(document, symbolsVisitor.symbols); + + return symbolsVisitor.symbols; + } +} diff --git a/pkgs/sass_language_services/lib/src/features/document_symbols/document_symbols_visitor.dart b/pkgs/sass_language_services/lib/src/features/document_symbols/document_symbols_visitor.dart new file mode 100644 index 0000000..706f24b --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/document_symbols/document_symbols_visitor.dart @@ -0,0 +1,279 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_api/sass_api.dart' as sass; +import 'package:sass_language_services/src/utils/sass_lsp_utils.dart'; + +import 'stylesheet_document_symbol.dart'; + +final quotes = RegExp('["\']'); +final deprecated = RegExp(r'///\s*@deprecated'); + +class DocumentSymbolsVisitor with sass.RecursiveStatementVisitor { + final symbols = []; + + DocumentSymbolsVisitor(); + + /// Sort out the relationship between parent and child symbols. + /// + /// The visitor begins with the leaf nodes and travels recursively + /// up before moving on to the next branch. We check to see + /// if the symbol we're collecting now contains (based on its range) + /// other symbols, and if so adds them as children. + void _collect( + {required String name, + required lsp.SymbolKind kind, + required lsp.Range symbolRange, + lsp.Range? nameRange, + String? docComment, + String? detail, + List? tags}) { + var range = symbolRange; + var selectionRange = nameRange; + if (selectionRange == null || !_containsRange(range, symbolRange)) { + selectionRange = lsp.Range(start: range.start, end: range.end); + } + + var symbol = StylesheetDocumentSymbol( + name: name, + kind: kind, + range: range, + children: [], + selectionRange: selectionRange, + docComment: docComment, + detail: detail, + tags: tags, + deprecated: _isDeprecated(docComment)); + + // Look to see if this symbol contains other symbols. + + var keep = []; + + for (var other in symbols) { + if (_containsRange(symbol.range, other.range)) { + symbol.children!.add(other); + } else { + keep.add(other); + } + } + + keep.add(symbol); + symbols.replaceRange(0, symbols.length, keep); + } + + bool _containsRange(lsp.Range a, lsp.Range b) { + if (b.start.line < a.start.line || b.end.line < a.start.line) { + return false; + } + + if (b.start.line > b.end.line || b.end.line > a.end.line) { + return false; + } + + if (b.start.line == a.start.line && b.start.character < a.start.character) { + return false; + } + + if (b.end.line == a.end.line && b.end.character > a.end.character) { + return false; + } + + return true; + } + + bool _isDeprecated(String? docComment) { + if (docComment != null && deprecated.hasMatch(docComment)) { + return true; + } + return false; + } + + String? _detail(sass.CallableDeclaration node) { + var arguments = node.arguments.arguments + .map((arg) => arg.defaultValue != null + ? '${arg.name}: ${arg.defaultValue!.span.text}' + : arg.name) + .join(', '); + return '($arguments)'; + } + + @override + void visitAtRule(node) { + super.visitAtRule(node); + + if (!node.name.isPlain) { + return; + } + + if (node.name.asPlain == 'font-face') { + _collect( + name: node.name.span.text, + kind: lsp.SymbolKind.Class, + symbolRange: toRange(node.span), + nameRange: toRange(node.name.span)); + } else if (node.name.asPlain!.startsWith('keyframes')) { + var keyframesName = node.span.context.split(' ').elementAtOrNull(1); + if (keyframesName != null) { + var keyframesNameRange = lsp.Range( + start: lsp.Position( + line: node.name.span.start.line, + character: node.name.span.end.column + 1), + end: lsp.Position( + line: node.name.span.end.line, + character: + node.name.span.end.column + 1 + keyframesName.length)); + + _collect( + name: keyframesName, + kind: lsp.SymbolKind.Class, + symbolRange: toRange(node.span), + nameRange: keyframesNameRange); + } + } + } + + @override + void visitDeclaration(node) { + super.visitDeclaration(node); + if (node.name.isPlain && node.name.asPlain!.startsWith("--")) { + _collect( + name: node.name.span.text, + kind: lsp.SymbolKind.Variable, + symbolRange: toRange(node.span), + nameRange: toRange(node.name.span)); + } + } + + @override + void visitFunctionRule(node) { + super.visitFunctionRule(node); + + _collect( + name: node.name, + detail: _detail(node), + kind: lsp.SymbolKind.Function, + docComment: node.comment?.docComment, + symbolRange: toRange(node.span), + nameRange: toRange(node.nameSpan)); + } + + @override + void visitMediaRule(node) { + super.visitMediaRule(node); + if (!node.query.isPlain) { + return; + } + + // node.query.span includes whitespace, so the range doesn't match node.query.asPlain + var nameRange = lsp.Range( + start: lsp.Position( + line: node.span.start.line + node.query.span.start.line, + character: node.span.start.column + node.query.span.start.column), + end: lsp.Position( + line: node.span.start.line + node.query.span.end.line, + character: node.span.start.column + + node.query.span.start.column + + node.query.asPlain!.length)); + + _collect( + name: '@media ${node.query.asPlain}', + kind: lsp.SymbolKind.Module, + symbolRange: toRange(node.span), + nameRange: nameRange); + } + + @override + void visitMixinRule(node) { + super.visitMixinRule(node); + + _collect( + name: node.name, + detail: _detail(node), + kind: lsp.SymbolKind.Method, + docComment: node.comment?.docComment, + symbolRange: toRange(node.span), + nameRange: toRange(node.nameSpan)); + } + + @override + void visitStyleRule(sass.StyleRule node) { + super.visitStyleRule(node); + + if (!node.selector.isPlain) { + // Keeping it simple for now. + return; + } + + try { + var selectorList = sass.SelectorList.parse(node.selector.asPlain!); + for (var complexSelector in selectorList.components) { + String? name; + lsp.Range? nameRange; + lsp.Range? symbolRange; + + for (var component in complexSelector.components) { + var selector = component.selector; + + if (name == null) { + name = selector.span.text; + } else { + name = '$name ${selector.span.text}'; + } + + if (nameRange == null) { + // The selector span seems to be relative to node, not to the file. + nameRange = lsp.Range( + start: lsp.Position( + line: node.span.start.line + selector.span.start.line, + character: + node.span.start.column + selector.span.start.column), + end: lsp.Position( + line: node.span.start.line + selector.span.end.line, + character: + node.span.start.column + selector.span.end.column)); + + // symbolRange: start position of selector's nameRange, end of stylerule (node.span.end). + symbolRange = lsp.Range( + start: lsp.Position( + line: nameRange.start.line, + character: nameRange.start.character), + end: lsp.Position( + line: node.span.end.line, character: node.span.end.column)); + } else { + // Move the end of the name range down to include this selector component + nameRange = lsp.Range( + start: nameRange.start, + end: lsp.Position( + line: node.span.start.line + selector.span.end.line, + character: + node.span.start.column + selector.span.end.column)); + } + } + + _collect( + name: name!, + kind: lsp.SymbolKind.Class, + symbolRange: symbolRange!, + nameRange: nameRange); + } + } on sass.SassFormatException catch (_) { + // Do nothing. + } + } + + @override + void visitVariableDeclaration(node) { + super.visitVariableDeclaration(node); + _collect( + name: node.nameSpan.text, + kind: lsp.SymbolKind.Variable, + docComment: node.comment?.docComment, + symbolRange: toRange(node.span), + nameRange: lsp.Range( + start: lsp.Position( + line: node.nameSpan.start.line, + // the span includes $ + character: node.nameSpan.start.column), + end: lsp.Position( + line: node.nameSpan.end.line, + character: node.nameSpan.end.column))); + } +} diff --git a/pkgs/sass_language_services/lib/src/features/document_symbols/stylesheet_document_symbol.dart b/pkgs/sass_language_services/lib/src/features/document_symbols/stylesheet_document_symbol.dart new file mode 100644 index 0000000..1af898e --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/document_symbols/stylesheet_document_symbol.dart @@ -0,0 +1,17 @@ +import 'package:lsp_server/lsp_server.dart'; + +class StylesheetDocumentSymbol extends DocumentSymbol { + final String? docComment; + + StylesheetDocumentSymbol({ + required super.name, + required super.kind, + required super.range, + required super.selectionRange, + super.tags, + super.deprecated, + super.detail, + super.children, + this.docComment, + }); +} diff --git a/pkgs/sass_language_services/lib/src/language_services.dart b/pkgs/sass_language_services/lib/src/language_services.dart index f9e58d6..515f3de 100644 --- a/pkgs/sass_language_services/lib/src/language_services.dart +++ b/pkgs/sass_language_services/lib/src/language_services.dart @@ -3,24 +3,26 @@ import 'package:sass_api/sass_api.dart' as sass; import 'package:sass_language_services/sass_language_services.dart'; import 'features/document_links/document_links_feature.dart'; +import 'features/document_symbols/document_symbols_feature.dart'; import 'language_services_cache.dart'; class LanguageServices { - late final LanguageServicesCache cache; + final LanguageServicesCache cache; final lsp.ClientCapabilities clientCapabilities; final FileSystemProvider fs; LanguageServerConfiguration configuration = LanguageServerConfiguration.create(null); - late final DocumentLinksFeature _links; + late final DocumentLinksFeature _documentLinks; + late final DocumentSymbolsFeature _documentSymbols; LanguageServices({ required this.clientCapabilities, required this.fs, - }) { - cache = LanguageServicesCache(); - _links = DocumentLinksFeature(ls: this); + }) : cache = LanguageServicesCache() { + _documentLinks = DocumentLinksFeature(ls: this); + _documentSymbols = DocumentSymbolsFeature(ls: this); } void configure(LanguageServerConfiguration configuration) { @@ -29,7 +31,11 @@ class LanguageServices { Future> findDocumentLinks( TextDocument document) { - return _links.findDocumentLinks(document); + return _documentLinks.findDocumentLinks(document); + } + + List findDocumentSymbols(TextDocument document) { + return _documentSymbols.findDocumentSymbols(document); } sass.Stylesheet parseStylesheet(TextDocument document) { diff --git a/pkgs/sass_language_services/lib/src/language_services_cache.dart b/pkgs/sass_language_services/lib/src/language_services_cache.dart index 91b256f..2b2a3c2 100644 --- a/pkgs/sass_language_services/lib/src/language_services_cache.dart +++ b/pkgs/sass_language_services/lib/src/language_services_cache.dart @@ -5,6 +5,7 @@ class CacheEntry { TextDocument document; sass.Stylesheet stylesheet; List? links; + List? symbols; CacheEntry({ required this.document, @@ -52,11 +53,20 @@ class LanguageServicesCache { return _cache[document.uri.toString()]?.links; } + List? getDocumentSymbols(TextDocument document) { + return _cache[document.uri.toString()]?.symbols; + } + void setDocumentLinks( TextDocument document, List links) { _cache[document.uri.toString()]?.links = links; } + void setDocumentSymbols( + TextDocument document, List symbols) { + _cache[document.uri.toString()]?.symbols = symbols; + } + Iterable getDocuments() { return _cache.values.map((e) => e.document); } diff --git a/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart b/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart new file mode 100644 index 0000000..f3c3b5b --- /dev/null +++ b/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart @@ -0,0 +1,11 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:source_span/source_span.dart'; + +lsp.Range toRange(FileSpan span) { + return lsp.Range( + start: lsp.Position( + line: span.start.line, + character: span.start.column, + ), + end: lsp.Position(line: span.end.line, character: span.end.column)); +} diff --git a/pkgs/sass_language_services/pubspec.yaml b/pkgs/sass_language_services/pubspec.yaml index e9462e4..b4dd0aa 100644 --- a/pkgs/sass_language_services/pubspec.yaml +++ b/pkgs/sass_language_services/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: lsp_server: ^0.3.2 path: ^1.9.0 sass_api: ^14.1.0 + source_span: ^1.10.0 dev_dependencies: lints: ^4.0.0 diff --git a/pkgs/sass_language_services/test/configuration_test.dart b/pkgs/sass_language_services/test/configuration_test.dart index 5f6fc16..607df35 100644 --- a/pkgs/sass_language_services/test/configuration_test.dart +++ b/pkgs/sass_language_services/test/configuration_test.dart @@ -30,7 +30,7 @@ void main() { var result = LanguageServerConfiguration.create(null); expect(result.workspace.exclude, - equals(["**/.git/**", "**/node_modules/**"])); + equals(["/**/.git/**", "/**/node_modules/**"])); expect(result.workspace.loadPaths, isEmpty); expect(result.workspace.importAliases, isEmpty); @@ -50,7 +50,7 @@ void main() { // else defaults expect(result.workspace.exclude, - equals(["**/.git/**", "**/node_modules/**"])); + equals(["/**/.git/**", "/**/node_modules/**"])); expect(result.workspace.importAliases, isEmpty); }); }); diff --git a/pkgs/sass_language_services/test/features/document_links/document_links_test.dart b/pkgs/sass_language_services/test/features/document_links/document_links_test.dart index 6c01bd4..2b4ab16 100644 --- a/pkgs/sass_language_services/test/features/document_links/document_links_test.dart +++ b/pkgs/sass_language_services/test/features/document_links/document_links_test.dart @@ -14,15 +14,15 @@ void main() { }); test('should resolve valid links', () async { - fs.createDocument('\$var: 1px;', uri: 'variables.scss'); - fs.createDocument('\$tr: 2px;', uri: 'corners.scss'); - fs.createDocument('\$b: #000;', uri: 'colors.scss'); + fs.createDocument(r'$var: 1px;', uri: 'variables.scss'); + fs.createDocument(r'$tr: 2px;', uri: 'corners.scss'); + fs.createDocument(r'$b: #000;', uri: 'colors.scss'); - var document = fs.createDocument(''' + var document = fs.createDocument(r''' @use "corners" as *; @use "variables" as vars; -@forward "colors" as color-* hide \$foo, barfunc; -@forward "./does-not-exist" as foo-* show \$public; +@forward "colors" as color-* hide $foo, barfunc; +@forward "./does-not-exist" as foo-* show $public; '''); var links = await ls.findDocumentLinks(document); @@ -54,9 +54,9 @@ void main() { }); test('should resolve various relative links', () async { - fs.createDocument('\$var: 1px;', uri: 'upper.scss'); - fs.createDocument('\$tr: 2px;', uri: 'middle/middle.scss'); - fs.createDocument('\$b: #000;', uri: 'middle/lower/lower.scss'); + fs.createDocument(r'$var: 1px;', uri: 'upper.scss'); + fs.createDocument(r'$tr: 2px;', uri: 'middle/middle.scss'); + fs.createDocument(r'$b: #000;', uri: 'middle/lower/lower.scss'); var document = fs.createDocument(''' @use "../upper"; @@ -70,14 +70,14 @@ void main() { }); test('should not break on circular references', () async { - fs.createDocument(''' + fs.createDocument(r''' @use "./pong" -\$var: ping +$var: ping ''', uri: 'ping.sass'); - var document = fs.createDocument(''' + var document = fs.createDocument(r''' @use "./pong" -\$var: ping +$var: ping ''', uri: 'ping.sass'); var links = await ls.findDocumentLinks(document); @@ -86,12 +86,12 @@ void main() { }); test('handles various forms of partials', () async { - fs.createDocument(''' -\$foo: blue; + fs.createDocument(r''' +$foo: blue; ''', uri: '_foo.scss'); - fs.createDocument(''' -\$bar: red + fs.createDocument(r''' +$bar: red ''', uri: 'bar/_index.sass'); var document = fs.createDocument(''' @@ -129,8 +129,8 @@ void main() { }); test('handles Sass imports without string quotes', () async { - fs.createDocument(''' -\$foo: blue; + fs.createDocument(r''' +$foo: blue; ''', uri: '_foo.scss'); var links = await ls.findDocumentLinks(fs.createDocument(''' diff --git a/pkgs/sass_language_services/test/features/document_symbols/document_symbols_test.dart b/pkgs/sass_language_services/test/features/document_symbols/document_symbols_test.dart new file mode 100644 index 0000000..a4852e9 --- /dev/null +++ b/pkgs/sass_language_services/test/features/document_symbols/document_symbols_test.dart @@ -0,0 +1,198 @@ +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:test/test.dart'; + +import '../../memory_file_system.dart'; +import '../../test_client_capabilities.dart'; + +final fs = MemoryFileSystem(); +final ls = LanguageServices(fs: fs, clientCapabilities: getCapabilities()); + +void main() { + group('selectors', () { + setUp(() { + ls.cache.clear(); + }); + + test('should collect CSS class selectors', () { + var document = fs.createDocument(''' +.foo { + color: red; +} + +.bar { + color: blue; +} +'''); + + var result = ls.findDocumentSymbols(document); + expect(result.length, equals(2)); + + expect(result.first.name, equals(".foo")); + expect(result.last.name, equals(".bar")); + }); + + test('should treat CSS selectors with multiple classes as one', () { + var document = fs.createDocument(''' +.foo.bar { + color: red; +} + +.fizz .buzz { + color: blue; +} +'''); + + var result = ls.findDocumentSymbols(document); + expect(result.length, equals(2)); + + expect(result.first.name, equals(".foo.bar")); + expect(result.last.name, equals(".fizz .buzz")); + }); + + test('should treat lists of selectors as separate', () { + var document = fs.createDocument(''' +.foo.bar, +.fizz .buzz { + color: red; +} +'''); + + var result = ls.findDocumentSymbols(document); + expect(result.length, equals(2)); + + expect(result.first.name, equals(".foo.bar")); + expect(result.last.name, equals(".fizz .buzz")); + }); + + test('should include extras', () { + var document = fs.createDocument(''' +.foo:has([data-testid="bar"]) { + color: red; +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.first.name, equals('.foo:has([data-testid="bar"])')); + }); + + test('placeholder selectors', () { + var document = fs.createDocument(''' +%waitforit { + color: red; +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.first.name, equals('%waitforit')); + }); + }); + + group('variables', () { + setUp(() { + ls.cache.clear(); + }); + + test('CSS variables', () { + var document = fs.createDocument(''' +.hello { + --world: blue; + color: var(--world); +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.length, equals(1)); + expect(result.first.name, equals('.hello')); + + expect(result.first.children!.length, equals(1)); + expect(result.first.children!.first.name, equals('--world')); + }); + + test('public variables', () { + var document = fs.createDocument(r''' +$world: blue; +.hello { + color: $world; +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.first.name, equals(r'$world')); + }); + }); + + group('callables', () { + setUp(() { + ls.cache.clear(); + }); + + test('functions', () { + var document = fs.createDocument(r''' +@function doStuff($a: 1, $b: 2) { + $value: $a + $b; + @return $value; +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.length, equals(1)); + expect(result.first.name, equals('doStuff')); + + expect(result.first.children!.length, equals(1)); + expect(result.first.children!.first.name, equals(r'$value')); + }); + + test('mixins', () { + var document = fs.createDocument(r''' +@mixin mixin1 { + $value: 1; + line-height: $value; +} +'''); + var result = ls.findDocumentSymbols(document); + + expect(result.length, equals(1)); + expect(result.first.name, equals(r'mixin1')); + + expect(result.first.children!.length, equals(1)); + expect(result.first.children!.first.name, equals(r'$value')); + }); + }); + + group('at-rules', () { + setUp(() { + ls.cache.clear(); + }); + + test('@media', () { + var document = fs.createDocument(r''' +@media screen, print { + body { + font-size: 14pt; + } +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.length, equals(1)); + expect(result.first.name, equals('@media screen, print')); + + expect(result.first.children!.length, equals(1)); + expect(result.first.children!.first.name, equals('body')); + }); + + test('@font-face', () { + var document = fs.createDocument(r''' +@font-face { + font-family: "Vulf Mono", monospace; +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.first.name, equals('font-face')); + }); + + test('@keyframes', () { + var document = fs.createDocument(r''' +@keyframes animation { + +} +'''); + var result = ls.findDocumentSymbols(document); + expect(result.first.name, equals('animation')); + }); + }); +} diff --git a/pkgs/sass_language_services/test/features/document_symbols/docyment_symbol_ranges_test.dart b/pkgs/sass_language_services/test/features/document_symbols/docyment_symbol_ranges_test.dart new file mode 100644 index 0000000..95cb92c --- /dev/null +++ b/pkgs/sass_language_services/test/features/document_symbols/docyment_symbol_ranges_test.dart @@ -0,0 +1,292 @@ +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:test/test.dart'; + +import '../../memory_file_system.dart'; +import '../../range_matchers.dart'; +import '../../test_client_capabilities.dart'; + +final fs = MemoryFileSystem(); +final ls = LanguageServices(fs: fs, clientCapabilities: getCapabilities()); + +void main() { + group('selectors', () { + setUp(() { + ls.cache.clear(); + }); + + test('CSS selector ranges are correct', () { + var document = fs.createDocument(''' +.foo { + color: red; +} + +.bar { + color: blue; +} +'''); + + var result = ls.findDocumentSymbols(document); + var fooNameRange = result.first.selectionRange; + var fooSymbolRange = result.first.range; + expect(fooNameRange, StartsAtLine(0)); + expect(fooNameRange, StartsAtCharacter(0)); + + expect(fooNameRange, EndsAtLine(0)); + expect(fooNameRange, EndsAtCharacter(4)); + + expect(fooSymbolRange, StartsAtLine(0)); + expect(fooSymbolRange, StartsAtCharacter(0)); + + expect(fooSymbolRange, EndsAtLine(2)); + expect(fooSymbolRange, EndsAtCharacter(1)); + + var barNameRange = result.last.selectionRange; + var barSymbolRange = result.last.range; + + expect(barNameRange, StartsAtLine(4)); + expect(barNameRange, StartsAtCharacter(0)); + + expect(barNameRange, EndsAtLine(4)); + expect(barNameRange, EndsAtCharacter(4)); + + expect(barSymbolRange, StartsAtLine(4)); + expect(barSymbolRange, StartsAtCharacter(0)); + + expect(barSymbolRange, EndsAtLine(6)); + expect(barSymbolRange, EndsAtCharacter(1)); + }); + + test('Sass indented selector ranges are correct', () { + var document = fs.createDocument(''' +.foo + color: red + +''', uri: 'index.sass'); + + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(0)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(4)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(1)); + expect(symbolRange, EndsAtCharacter(12)); + }); + + test('placeholder selector ranges are correct', () { + var document = fs.createDocument(''' +%waitforit { + color: red; +} +'''); + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(0)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(10)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(2)); + expect(symbolRange, EndsAtCharacter(1)); + }); + }); + + group('variables', () { + setUp(() { + ls.cache.clear(); + }); + + test('CSS variable ranges are correct', () { + var document = fs.createDocument(''' +.hello { + --world: blue; + color: var(--world); +} +'''); + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.children!.first.selectionRange; + var symbolRange = result.first.children!.first.range; + + expect(nameRange, StartsAtLine(1)); + expect(nameRange, StartsAtCharacter(2)); + + expect(nameRange, EndsAtLine(1)); + expect(nameRange, EndsAtCharacter(9)); + + expect(symbolRange, StartsAtLine(1)); + expect(symbolRange, StartsAtCharacter(2)); + + expect(symbolRange, EndsAtLine(1)); + expect(symbolRange, EndsAtCharacter(15)); // excluding ; + }); + + test('Sass variable ranges are correct', () { + var document = fs.createDocument(r''' +$world: blue; +.hello { + color: $world; +} +'''); + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(0)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(6)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(0)); + expect(symbolRange, EndsAtCharacter(12)); // excluding ; + }); + }); + + group('callable', () { + setUp(() { + ls.cache.clear(); + }); + + test('function ranges are correct', () { + var document = fs.createDocument(r''' +@function doStuff($a: 1, $b: 2) { + $value: $a + $b; + @return $value; +} +'''); + + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(10)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(17)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(3)); + expect(symbolRange, EndsAtCharacter(1)); + }); + + test('mixin ranges are correct', () { + var document = fs.createDocument(r''' +@mixin mixin1 { + $value: 1; + line-height: $value; +} +'''); + + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(7)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(13)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(3)); + expect(symbolRange, EndsAtCharacter(1)); + }); + }); + + group('at-rules', () { + setUp(() { + ls.cache.clear(); + }); + + test('@media ranges are correct', () { + var document = fs.createDocument(r''' +@media screen, print + body + font-size: 14pt +''', uri: 'index.sass'); + + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(7)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(20)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(2)); + expect(symbolRange, EndsAtCharacter(19)); + }); + + test('@font-face ranges are correct', () { + var document = fs.createDocument(r''' +@font-face { + font-family: "Vulf Mono", monospace; +} +'''); + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(1)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(10)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(2)); + expect(symbolRange, EndsAtCharacter(1)); + }); + + test('@keyframes', () { + var document = fs.createDocument(r''' +@keyframes animation { + +} +'''); + var result = ls.findDocumentSymbols(document); + var nameRange = result.first.selectionRange; + var symbolRange = result.first.range; + + expect(nameRange, StartsAtLine(0)); + expect(nameRange, StartsAtCharacter(11)); + + expect(nameRange, EndsAtLine(0)); + expect(nameRange, EndsAtCharacter(20)); + + expect(symbolRange, StartsAtLine(0)); + expect(symbolRange, StartsAtCharacter(0)); + + expect(symbolRange, EndsAtLine(2)); + expect(symbolRange, EndsAtCharacter(1)); + }); + }); +} diff --git a/pkgs/sass_language_services/test/range_matchers.dart b/pkgs/sass_language_services/test/range_matchers.dart new file mode 100644 index 0000000..41bd43d --- /dev/null +++ b/pkgs/sass_language_services/test/range_matchers.dart @@ -0,0 +1,26 @@ +import 'package:lsp_server/lsp_server.dart'; +import 'package:test/test.dart'; + +class StartsAtLine extends CustomMatcher { + StartsAtLine(Object? valueOrMatcher) + : super('Range starts at line', 'line', valueOrMatcher); + featureValueOf(actual) => (actual as Range).start.line; +} + +class StartsAtCharacter extends CustomMatcher { + StartsAtCharacter(Object? valueOrMatcher) + : super('Range starts at character', 'character', valueOrMatcher); + featureValueOf(actual) => (actual as Range).start.character; +} + +class EndsAtLine extends CustomMatcher { + EndsAtLine(Object? valueOrMatcher) + : super('Range ends at line', 'line', valueOrMatcher); + featureValueOf(actual) => (actual as Range).end.line; +} + +class EndsAtCharacter extends CustomMatcher { + EndsAtCharacter(Object? valueOrMatcher) + : super('Range ends at character', 'character', valueOrMatcher); + featureValueOf(actual) => (actual as Range).end.character; +}