diff --git a/extension/test/electron/definition/selection-ranges.test.js b/extension/test/electron/definition/selection-ranges.test.js new file mode 100644 index 0000000..fa96889 --- /dev/null +++ b/extension/test/electron/definition/selection-ranges.test.js @@ -0,0 +1,39 @@ +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.scss') +); + +before(async () => { + await showFile(stylesUri); + await sleepCI(); +}); + +after(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); +}); + +/** + * @param {import('vscode').Uri} documentUri + * @param {Array} positions + * @returns {Promise>} + */ +async function getSelectionRanges(documentUri, positions) { + const result = await vscode.commands.executeCommand( + 'vscode.executeSelectionRangeProvider', + documentUri, + positions + ); + return result; +} + +test('gets document selection ranges', async () => { + const [result] = await getSelectionRanges(stylesUri, [ + new vscode.Position(7, 5), + ]); + + assert.ok(result, 'Should have gotten selection ranges'); +}); diff --git a/pkgs/sass_language_server/lib/src/language_server.dart b/pkgs/sass_language_server/lib/src/language_server.dart index 11171b4..18f1a07 100644 --- a/pkgs/sass_language_server/lib/src/language_server.dart +++ b/pkgs/sass_language_server/lib/src/language_server.dart @@ -178,6 +178,7 @@ class LanguageServer { documentSymbolProvider: Either2.t1(true), referencesProvider: Either2.t1(true), renameProvider: Either2.t2(RenameOptions(prepareProvider: true)), + selectionRangeProvider: Either3.t1(true), textDocumentSync: Either2.t1(TextDocumentSyncKind.Incremental), workspaceSymbolProvider: Either2.t1(true), ); @@ -432,6 +433,29 @@ class LanguageServer { } }); + _connection.onSelectionRanges((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { + return []; + } + + var configuration = _getLanguageConfiguration(document); + if (configuration.selectionRanges.enabled) { + var result = _ls.getSelectionRanges( + document, + params.positions, + ); + return result; + } else { + return []; + } + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } + }); + // TODO: add this handler upstream Future> onWorkspaceSymbol(dynamic params) async { try { 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 47ada6d..74ba5eb 100644 --- a/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart +++ b/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart @@ -11,6 +11,7 @@ class LanguageConfiguration { late final FeatureConfiguration documentLinks; late final FeatureConfiguration references; late final FeatureConfiguration rename; + late final FeatureConfiguration selectionRanges; late final FeatureConfiguration workspaceSymbols; LanguageConfiguration.from(dynamic config) { @@ -26,6 +27,8 @@ class LanguageConfiguration { enabled: config?['references']?['enabled'] as bool? ?? true); rename = FeatureConfiguration( enabled: config?['rename']?['enabled'] as bool? ?? true); + selectionRanges = FeatureConfiguration( + enabled: config?['selectionRanges']?['enabled'] as bool? ?? true); workspaceSymbols = FeatureConfiguration( enabled: config?['workspaceSymbols']?['enabled'] as bool? ?? true); } diff --git a/pkgs/sass_language_services/lib/src/features/node_at_offset_visitor.dart b/pkgs/sass_language_services/lib/src/features/node_at_offset_visitor.dart index 2f42737..005a5bf 100644 --- a/pkgs/sass_language_services/lib/src/features/node_at_offset_visitor.dart +++ b/pkgs/sass_language_services/lib/src/features/node_at_offset_visitor.dart @@ -21,6 +21,9 @@ class NodeAtOffsetVisitor /// Finds the node with the shortest span at [offset]. NodeAtOffsetVisitor(int offset) : _offset = offset; + /// Here to allow subclasses to do something with each candidate. + void processCandidate(sass.AstNode node) {} + sass.AstNode? _process(sass.AstNode node) { var nodeSpan = node.span; var nodeStartOffset = nodeSpan.start.offset; @@ -30,6 +33,7 @@ class NodeAtOffsetVisitor if (containsOffset) { if (candidate == null) { candidate = node; + processCandidate(node); } else { var nodeLength = nodeEndOffset - nodeStartOffset; // store candidateSpan next to _candidate @@ -38,6 +42,7 @@ class NodeAtOffsetVisitor candidateSpan.end.offset - candidateSpan.start.offset; if (nodeLength <= candidateLength) { candidate = node; + processCandidate(node); } } } diff --git a/pkgs/sass_language_services/lib/src/features/selection_ranges/selection_ranges_feature.dart b/pkgs/sass_language_services/lib/src/features/selection_ranges/selection_ranges_feature.dart new file mode 100644 index 0000000..498847d --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/selection_ranges/selection_ranges_feature.dart @@ -0,0 +1,58 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:sass_language_services/src/features/selection_ranges/selection_ranges_visitor.dart'; +import 'package:sass_language_services/src/utils/sass_lsp_utils.dart'; + +import '../go_to_definition/scope_visitor.dart'; +import '../go_to_definition/scoped_symbols.dart'; +import '../language_feature.dart'; + +class SelectionRangesFeature extends LanguageFeature { + SelectionRangesFeature({required super.ls}); + + List getSelectionRanges( + TextDocument document, List positions) { + var stylesheet = ls.parseStylesheet(document); + var symbols = ls.cache.getDocumentSymbols(document) ?? + ScopedSymbols( + stylesheet, + document.languageId == 'sass' ? Dialect.indented : Dialect.scss, + ); + ls.cache.setDocumentSymbols(document, symbols); + + var result = []; + + for (var position in positions) { + var visitor = SelectionRangesVisitor( + document.offsetAt(position), + ); + stylesheet.accept(visitor); + + var ranges = visitor.ranges; + lsp.SelectionRange? current; + for (var i = ranges.length - 1; i >= 0; i--) { + var range = ranges[i]; + + // Avoid duplicates + if (current != null && isSameRange(current.range, range.range)) { + continue; + } + + current = lsp.SelectionRange( + range: range.range, + parent: current, + ); + } + if (current == null) { + result.add( + lsp.SelectionRange( + range: lsp.Range(start: position, end: position), + ), + ); + } + result.add(current!); + } + + return result; + } +} diff --git a/pkgs/sass_language_services/lib/src/features/selection_ranges/selection_ranges_visitor.dart b/pkgs/sass_language_services/lib/src/features/selection_ranges/selection_ranges_visitor.dart new file mode 100644 index 0000000..35e039a --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/selection_ranges/selection_ranges_visitor.dart @@ -0,0 +1,20 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_api/sass_api.dart' as sass; + +import '../../utils/sass_lsp_utils.dart'; +import '../node_at_offset_visitor.dart'; + +class SelectionRangesVisitor extends NodeAtOffsetVisitor { + final ranges = []; + + SelectionRangesVisitor(super._offset); + + @override + void processCandidate(sass.AstNode node) { + ranges.add(lsp.SelectionRange(range: toRange(node.span))); + + if (node is sass.Declaration) { + ranges.add(lsp.SelectionRange(range: toRange(node.name.span))); + } + } +} diff --git a/pkgs/sass_language_services/lib/src/language_services.dart b/pkgs/sass_language_services/lib/src/language_services.dart index 8b527ce..c16cff8 100644 --- a/pkgs/sass_language_services/lib/src/language_services.dart +++ b/pkgs/sass_language_services/lib/src/language_services.dart @@ -5,6 +5,7 @@ import 'package:sass_language_services/src/features/document_highlights/document import 'package:sass_language_services/src/features/find_references/find_references_feature.dart'; import 'package:sass_language_services/src/features/go_to_definition/go_to_definition_feature.dart'; import 'package:sass_language_services/src/features/rename/rename_feature.dart'; +import 'package:sass_language_services/src/features/selection_ranges/selection_ranges_feature.dart'; import 'features/document_links/document_links_feature.dart'; import 'features/document_symbols/document_symbols_feature.dart'; @@ -25,6 +26,7 @@ class LanguageServices { late final GoToDefinitionFeature _goToDefinition; late final FindReferencesFeature _findReferences; late final RenameFeature _rename; + late final SelectionRangesFeature _selectionRanges; late final WorkspaceSymbolsFeature _workspaceSymbols; LanguageServices({ @@ -37,6 +39,7 @@ class LanguageServices { _goToDefinition = GoToDefinitionFeature(ls: this); _findReferences = FindReferencesFeature(ls: this); _rename = RenameFeature(ls: this); + _selectionRanges = SelectionRangesFeature(ls: this); _workspaceSymbols = WorkspaceSymbolsFeature(ls: this); } @@ -67,6 +70,11 @@ class LanguageServices { return _workspaceSymbols.findWorkspaceSymbols(query); } + List getSelectionRanges( + TextDocument document, List positions) { + return _selectionRanges.getSelectionRanges(document, positions); + } + Future goToDefinition( TextDocument document, lsp.Position position) { return _goToDefinition.goToDefinition(document, position); diff --git a/pkgs/sass_language_services/test/features/selection_ranges/selection_ranges_test.dart b/pkgs/sass_language_services/test/features/selection_ranges/selection_ranges_test.dart new file mode 100644 index 0000000..2ca2152 --- /dev/null +++ b/pkgs/sass_language_services/test/features/selection_ranges/selection_ranges_test.dart @@ -0,0 +1,80 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:test/test.dart'; + +import '../../memory_file_system.dart'; +import '../../position_utils.dart'; +import '../../test_client_capabilities.dart'; + +final fs = MemoryFileSystem(); +final ls = LanguageServices(fs: fs, clientCapabilities: getCapabilities()); + +void expectRanges(TextDocument document, lsp.SelectionRange ranges, + List<(int, String)> expected) { + var pairs = <(int, String)>[]; + lsp.SelectionRange? current = ranges; + while (current != null) { + pairs.add(( + document.offsetAt(current.range.start), + document.getText(range: current.range), + )); + current = current.parent; + } + expect(pairs, equals(expected)); +} + +void main() { + group('selection ranges', () { + setUp(() { + ls.cache.clear(); + }); + + test('style rules', () { + var document = fs.createDocument('''.foo { + color: red; + + .bar { + color: blue; + } +} +'''); + + var result = ls.getSelectionRanges(document, [position(4, 5)]); + expect(result, hasLength(1)); + expectRanges(document, result.first, [ + (0, ".foo {\n color: red;\n\n .bar {\n color: blue;\n }\n}\n"), + (0, ".foo {\n color: red;\n\n .bar {\n color: blue;\n }\n}"), + (24, ".bar {\n color: blue;\n }"), + (35, "color: blue"), + (35, "color") + ]); + }); + + test('mixin rules', () { + var document = fs.createDocument('''@mixin foo { + color: red; + + .bar { + color: blue; + } +} +'''); + + var result = ls.getSelectionRanges(document, [position(4, 5)]); + expect(result, hasLength(1)); + expectRanges(document, result.first, [ + ( + 0, + "@mixin foo {\n color: red;\n\n .bar {\n color: blue;\n }\n}\n" + ), + ( + 0, + "@mixin foo {\n color: red;\n\n .bar {\n color: blue;\n }\n}" + ), + (30, ".bar {\n color: blue;\n }"), + (41, "color: blue"), + (41, "color") + ]); + }); + }); +}