From a7c2eb9ff87b1ea6cf6921a82bb58b0337939dda Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Nov 2024 21:55:36 +0100 Subject: [PATCH 1/8] Refactor dart:io out of language_server Try to isolate non-web code to bin/ --- .../bin/sass_language_server.dart | 36 +- .../lib/src/language_server.dart | 721 +++++++++--------- 2 files changed, 375 insertions(+), 382 deletions(-) diff --git a/pkgs/sass_language_server/bin/sass_language_server.dart b/pkgs/sass_language_server/bin/sass_language_server.dart index ab36b92..66f2383 100644 --- a/pkgs/sass_language_server/bin/sass_language_server.dart +++ b/pkgs/sass_language_server/bin/sass_language_server.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:lsp_server/lsp_server.dart'; import 'package:sass_language_server/sass_language_server.dart'; void main(List arguments) async { @@ -35,9 +38,10 @@ Logging options: var fileSystemProvider = LocalFileSystem(); var server = LanguageServer(); + Connection connection; + Socket? socket; if (transport == '--stdio') { - await server.start( - logLevel: logLevel, fileSystemProvider: fileSystemProvider); + connection = Connection(stdin, stdout); } else { // The client is the one listening to socket connections on the specified port. // In other words the language server is a _client_ for the socket transport. @@ -46,11 +50,31 @@ Logging options: // the language server. var split = transport.split('='); int port = int.parse(split.last); + socket = await Socket.connect('127.0.0.1', port); + connection = Connection(socket, socket); + } + + try { + exitCode = 1; await server.start( - logLevel: logLevel, - fileSystemProvider: fileSystemProvider, - transport: Transport.socket, - port: port); + connection: connection, + logLevel: logLevel, + fileSystemProvider: fileSystemProvider, + ); + + // See + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#shutdown + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#exit + connection.onShutdown(() async { + socket?.close(); + exitCode = 0; + }); + + connection.onExit(() async { + exit(exitCode); + }); + } on Exception catch (_) { + exit(1); } } diff --git a/pkgs/sass_language_server/lib/src/language_server.dart b/pkgs/sass_language_server/lib/src/language_server.dart index 31ddd96..3d98f68 100644 --- a/pkgs/sass_language_server/lib/src/language_server.dart +++ b/pkgs/sass_language_server/lib/src/language_server.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:lsp_server/lsp_server.dart'; import 'package:sass_language_server/src/lsp/remote_console.dart'; import 'package:sass_language_server/src/lsp/text_documents.dart'; @@ -14,7 +12,6 @@ const scannerMaxDepth = 256; class LanguageServer { late Connection _connection; - Socket? _socket; late ClientCapabilities _clientCapabilities; late LanguageServices _ls; late Uri _workspaceRoot; @@ -56,460 +53,432 @@ class LanguageServer { } Future start({ - Transport transport = Transport.stdio, + required Connection connection, required FileSystemProvider fileSystemProvider, String logLevel = "info", int? port, }) async { - // See - // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#shutdown - // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#exit - var exitCode = 1; - - // handle SocketException - try { - Future? initialScan; - - if (transport == Transport.socket) { - if (port == null) { - throw 'Port is required for socket transport'; - } - _socket = await Socket.connect('127.0.0.1', port); - _connection = Connection(_socket!, _socket!); - } else { - _connection = Connection(stdin, stdout); - } - _log = Logger(RemoteConsole(_connection), level: logLevel); - - _documents = TextDocuments( - connection: _connection, - onDidChangeContent: (params) async { - try { - // Update the cache with the new version of the document. - _ls.cache.onDocumentChanged(params.document); - if (initialScan != null) { - await initialScan; - } - // TODO: doDiagnostics - } on Exception catch (e) { - _log.debug(e.toString()); - } - }); + _connection = connection; - _log.info('sass-language-server is running'); + Future? initialScan; - Future scan(Uri uri, {int depth = 0}) async { - if (depth > scannerMaxDepth) { - return; - } + _log = Logger(RemoteConsole(_connection), level: logLevel); - try { - var document = _ls.cache.getDocument(uri); - if (document == null) { - var text = await fileSystemProvider.readFile(uri); - document = TextDocument( - uri, - uri.path.endsWith('.sass') - ? 'sass' - : uri.path.endsWith('.css') - ? 'css' - : 'scss', - 1, - text, - ); - - _ls.parseStylesheet(document); + _documents = TextDocuments( + connection: _connection, + onDidChangeContent: (params) async { + try { + // Update the cache with the new version of the document. + _ls.cache.onDocumentChanged(params.document); + if (initialScan != null) { + await initialScan; + } + // TODO: doDiagnostics + } on Exception catch (e) { + _log.debug(e.toString()); } + }); - var links = await _ls.findDocumentLinks(document); - for (var link in links) { - if (link.target == null) continue; - - var target = link.target.toString(); - if (target.contains('#{')) continue; - // Our findFiles glob will handle the initial parsing of CSS files - if (target.endsWith('.css')) continue; - // Sass built-ins are not files we can scan. - if (target.startsWith('sass:')) continue; + _log.info('sass-language-server is running'); - var visited = _ls.cache.getDocument(link.target as Uri); - if (visited != null) { - // avoid infinite loop in case of circular references - continue; - } - - try { - await scan(link.target!, depth: depth + 1); - } on Exception catch (e) { - // continue - _log.debug(e.toString()); - } - } - } on Exception catch (e) { - // Something went wrong parsing this file, try parsing the others - _log.debug(e.toString()); - } + Future scan(Uri uri, {int depth = 0}) async { + if (depth > scannerMaxDepth) { + return; } - _connection.onInitialize((params) async { - if (params.rootUri == null) { - throw 'rootUri is required in InitializeParams'; + try { + var document = _ls.cache.getDocument(uri); + if (document == null) { + var text = await fileSystemProvider.readFile(uri); + document = TextDocument( + uri, + uri.path.endsWith('.sass') + ? 'sass' + : uri.path.endsWith('.css') + ? 'css' + : 'scss', + 1, + text, + ); + + _ls.parseStylesheet(document); } - _clientCapabilities = params.capabilities; + var links = await _ls.findDocumentLinks(document); + for (var link in links) { + if (link.target == null) continue; + + var target = link.target.toString(); + if (target.contains('#{')) continue; + // Our findFiles glob will handle the initial parsing of CSS files + 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) { + // avoid infinite loop in case of circular references + continue; + } - if (params.rootPath case var rootPath?) { - _workspaceRoot = filePathToUri(rootPath); - } else if (params.rootUri case var rootUri?) { - _workspaceRoot = rootUri; - } else { - throw 'Got neither rootPath or rootUri in initialize params'; + try { + await scan(link.target!, depth: depth + 1); + } on Exception catch (e) { + // continue + _log.debug(e.toString()); + } } + } on Exception catch (e) { + // Something went wrong parsing this file, try parsing the others + _log.debug(e.toString()); + } + } - _log.debug('workspace root $_workspaceRoot'); - - _ls = LanguageServices( - clientCapabilities: _clientCapabilities, fs: fileSystemProvider); - - var serverCapabilities = ServerCapabilities( - definitionProvider: Either2.t1(true), - documentHighlightProvider: Either2.t1(true), - documentLinkProvider: DocumentLinkOptions(resolveProvider: false), - documentSymbolProvider: Either2.t1(true), - foldingRangeProvider: Either3.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), - ); + _connection.onInitialize((params) async { + if (params.rootUri == null) { + throw 'rootUri is required in InitializeParams'; + } - var result = InitializeResult(capabilities: serverCapabilities); - return result; - }); + _clientCapabilities = params.capabilities; - _connection.onNotification('workspace/didChangeConfiguration', - (params) async { - if (params.value is Map && params.value['settings'] is Map) { - _applyConfiguration(params.value['settings'] as Map); - } else { - _log.info( - 'workspace/didChangeConfiguration did not get expected parameters'); - } - }); - - _connection.onInitialized((params) async { - try { - initialScan = Future(() async { - _log.debug('Requesting user configuration'); - try { - var response = await _connection - .sendRequest>('workspace/configuration', { - 'items': [ - {'section': 'editor'}, - {'section': 'sass'}, - ] - }); - var settings = { - "editor": response.first, - "sass": response.last, - }; - _applyConfiguration(settings); - } catch (e) { - _log.warn(e.toString()); - } + if (params.rootPath case var rootPath?) { + _workspaceRoot = filePathToUri(rootPath); + } else if (params.rootUri case var rootUri?) { + _workspaceRoot = rootUri; + } else { + throw 'Got neither rootPath or rootUri in initialize params'; + } - _log.debug('Searching workspace for files'); - var files = await fileSystemProvider.findFiles('**.{css,scss,sass}', - root: _workspaceRoot.toFilePath(), - exclude: _ls.configuration.workspace.exclude); - _log.debug('Found ${files.length} files in workspace'); - for (var uri in files) { - if (uri.path.contains('/_')) { - // 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. - continue; - } - _log.debug('Scanning $uri'); - await scan(uri); + _log.debug('workspace root $_workspaceRoot'); + + _ls = LanguageServices( + clientCapabilities: _clientCapabilities, fs: fileSystemProvider); + + var serverCapabilities = ServerCapabilities( + definitionProvider: Either2.t1(true), + documentHighlightProvider: Either2.t1(true), + documentLinkProvider: DocumentLinkOptions(resolveProvider: false), + documentSymbolProvider: Either2.t1(true), + foldingRangeProvider: Either3.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), + ); + + var result = InitializeResult(capabilities: serverCapabilities); + return result; + }); + + _connection.onNotification('workspace/didChangeConfiguration', + (params) async { + if (params.value is Map && params.value['settings'] is Map) { + _applyConfiguration(params.value['settings'] as Map); + } else { + _log.info( + 'workspace/didChangeConfiguration did not get expected parameters'); + } + }); + + _connection.onInitialized((params) async { + try { + initialScan = Future(() async { + _log.debug('Requesting user configuration'); + try { + var response = await _connection + .sendRequest>('workspace/configuration', { + 'items': [ + {'section': 'editor'}, + {'section': 'sass'}, + ] + }); + var settings = { + "editor": response.first, + "sass": response.last, + }; + _applyConfiguration(settings); + } catch (e) { + _log.warn(e.toString()); + } + + _log.debug('Searching workspace for files'); + var files = await fileSystemProvider.findFiles('**.{css,scss,sass}', + root: _workspaceRoot.toFilePath(), + exclude: _ls.configuration.workspace.exclude); + _log.debug('Found ${files.length} files in workspace'); + for (var uri in files) { + if (uri.path.contains('/_')) { + // 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. + continue; } - }); - await initialScan; - initialScan = null; // all done - _log.debug('Finished initial scan of workspace'); - } on Exception catch (e) { - _log.error(e.toString()); - } - }); + _log.debug('Scanning $uri'); + await scan(uri); + } + }); + await initialScan; + initialScan = null; // all done + _log.debug('Finished initial scan of workspace'); + } on Exception catch (e) { + _log.error(e.toString()); + } + }); - _connection.onDefinition((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) return null; + _connection.onDefinition((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) return null; - var configuration = _getLanguageConfiguration(document); - if (configuration.definition.enabled) { - if (initialScan != null) { - await initialScan; - } - var result = await _ls.goToDefinition(document, params.position); - if (result is Location) { - return Either3.t1(result); - } else { - return null; - } + var configuration = _getLanguageConfiguration(document); + if (configuration.definition.enabled) { + if (initialScan != null) { + await initialScan; + } + var result = await _ls.goToDefinition(document, params.position); + if (result is Location) { + return Either3.t1(result); } else { return null; } - } on Exception catch (e) { - _log.debug(e.toString()); + } else { return null; } - }); - - _connection.onDocumentHighlight((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) return []; + } on Exception catch (e) { + _log.debug(e.toString()); + return null; + } + }); - var configuration = _getLanguageConfiguration(document); - if (configuration.highlights.enabled) { - if (initialScan != null) { - await initialScan; - } + _connection.onDocumentHighlight((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) return []; - var result = _ls.findDocumentHighlights(document, params.position); - return result; - } else { - return []; + var configuration = _getLanguageConfiguration(document); + if (configuration.highlights.enabled) { + if (initialScan != null) { + await initialScan; } - } on Exception catch (e) { - _log.debug(e.toString()); + + var result = _ls.findDocumentHighlights(document, params.position); + return result; + } else { return []; } - }); + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } + }); - _connection.onDocumentLinks((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) return []; + _connection.onDocumentLinks((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) return []; - var configuration = _getLanguageConfiguration(document); - if (configuration.documentLinks.enabled) { - if (initialScan != null) { - await initialScan; - } - - var result = await _ls.findDocumentLinks(document); - return result; - } else { - return []; + var configuration = _getLanguageConfiguration(document); + if (configuration.documentLinks.enabled) { + if (initialScan != null) { + await initialScan; } - } on Exception catch (e) { - _log.debug(e.toString()); + + var result = await _ls.findDocumentLinks(document); + return result; + } else { return []; } - }); - - // TODO: upstream allowing DocumentSymbol here - Future> onDocumentSymbol(dynamic params) async { - try { - var documentSymbolParams = DocumentSymbolParams.fromJson( - params.value as Map); + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } + }); - var document = _documents.get(documentSymbolParams.textDocument.uri); - if (document == null) return []; + // TODO: upstream allowing DocumentSymbol here + Future> onDocumentSymbol(dynamic params) async { + try { + var documentSymbolParams = + DocumentSymbolParams.fromJson(params.value as Map); - var configuration = _getLanguageConfiguration(document); - if (configuration.documentSymbols.enabled) { - if (initialScan != null) { - await initialScan; - } + var document = _documents.get(documentSymbolParams.textDocument.uri); + if (document == null) return []; - var result = _ls.findDocumentSymbols(document); - return Future.value(result); - } else { - return []; + var configuration = _getLanguageConfiguration(document); + if (configuration.documentSymbols.enabled) { + if (initialScan != null) { + await initialScan; } - } on Exception catch (e) { - _log.debug(e.toString()); + + 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.onReferences((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) return []; + _connection.peer + .registerMethod('textDocument/documentSymbol', onDocumentSymbol); - var configuration = _getLanguageConfiguration(document); - if (configuration.references.enabled) { - if (initialScan != null) { - await initialScan; - } + _connection.onReferences((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) return []; - var result = await _ls.findReferences( - document, - params.position, - params.context, - ); - return result; - } else { - return []; + var configuration = _getLanguageConfiguration(document); + if (configuration.references.enabled) { + if (initialScan != null) { + await initialScan; } - } on Exception catch (e) { - _log.debug(e.toString()); + + var result = await _ls.findReferences( + document, + params.position, + params.context, + ); + return result; + } else { return []; } - }); - - _connection.onPrepareRename((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) { - return Either2.t2( - Either3.t2( - PrepareRenameResult2(defaultBehavior: true), - ), - ); - } - - var configuration = _getLanguageConfiguration(document); - if (configuration.rename.enabled) { - if (initialScan != null) { - await initialScan; - } + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } + }); - var result = await _ls.prepareRename( - document, - params.position, - ); - return Either2.t2(result); - } else { - return Either2.t2( - Either3.t2( - PrepareRenameResult2(defaultBehavior: true), - ), - ); - } - } on Exception catch (e) { - _log.debug(e.toString()); + _connection.onPrepareRename((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { return Either2.t2( Either3.t2( PrepareRenameResult2(defaultBehavior: true), ), ); } - }); - _connection.onRenameRequest((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) { - return WorkspaceEdit(); + var configuration = _getLanguageConfiguration(document); + if (configuration.rename.enabled) { + if (initialScan != null) { + await initialScan; } - var configuration = _getLanguageConfiguration(document); - if (configuration.rename.enabled) { - if (initialScan != null) { - await initialScan; - } + var result = await _ls.prepareRename( + document, + params.position, + ); + return Either2.t2(result); + } else { + return Either2.t2( + Either3.t2( + PrepareRenameResult2(defaultBehavior: true), + ), + ); + } + } on Exception catch (e) { + _log.debug(e.toString()); + return Either2.t2( + Either3.t2( + PrepareRenameResult2(defaultBehavior: true), + ), + ); + } + }); - var result = await _ls.rename( - document, - params.position, - params.newName, - ); - return result; - } else { - return WorkspaceEdit(); - } - } on Exception catch (e) { - _log.debug(e.toString()); + _connection.onRenameRequest((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { return WorkspaceEdit(); } - }); - _connection.onFoldingRanges((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) { - return []; + var configuration = _getLanguageConfiguration(document); + if (configuration.rename.enabled) { + if (initialScan != null) { + await initialScan; } - var configuration = _getLanguageConfiguration(document); - if (configuration.foldingRanges.enabled) { - var result = _ls.getFoldingRanges(document); - return result; - } else { - return []; - } - } on Exception catch (e) { - _log.debug(e.toString()); - return []; + var result = await _ls.rename( + document, + params.position, + params.newName, + ); + return result; + } else { + return WorkspaceEdit(); } - }); - - _connection.onSelectionRanges((params) async { - try { - var document = _documents.get(params.textDocument.uri); - if (document == null) { - return []; - } + } on Exception catch (e) { + _log.debug(e.toString()); + return WorkspaceEdit(); + } + }); - 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()); + _connection.onFoldingRanges((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { return []; } - }); - // TODO: add this handler upstream - Future> onWorkspaceSymbol(dynamic params) async { - try { - var query = - (params.value as Map)['query'] as String?; + var configuration = _getLanguageConfiguration(document); + if (configuration.foldingRanges.enabled) { + var result = _ls.getFoldingRanges(document); + return result; + } else { + return []; + } + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } + }); - if (initialScan != null) { - await initialScan; - } + _connection.onSelectionRanges((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { + return []; + } - var result = _ls.findWorkspaceSymbols(query); + var configuration = _getLanguageConfiguration(document); + if (configuration.selectionRanges.enabled) { + var result = _ls.getSelectionRanges( + document, + params.positions, + ); return result; - } on Exception catch (e) { - _log.debug(e.toString()); + } else { return []; } + } on Exception catch (e) { + _log.debug(e.toString()); + return []; } + }); - _connection.peer.registerMethod('workspace/symbol', onWorkspaceSymbol); - - _connection.onShutdown(() async { - await _socket?.close(); - exitCode = 0; - }); + // TODO: add this handler upstream + Future> onWorkspaceSymbol(dynamic params) async { + try { + var query = (params.value as Map)['query'] as String?; - _connection.onExit(() async { - exit(exitCode); - }); + if (initialScan != null) { + await initialScan; + } - _connection.listen(); - } on Exception catch (e) { - _log.error(e.toString()); - exit(exitCode); + var result = _ls.findWorkspaceSymbols(query); + return result; + } on Exception catch (e) { + _log.debug(e.toString()); + return []; + } } + + _connection.peer.registerMethod('workspace/symbol', onWorkspaceSymbol); + + _connection.listen(); } } From cbb6114a83d5401926902d5e8982d22120e56739 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 30 Nov 2024 22:03:35 +0100 Subject: [PATCH 2/8] Look into how the client tells the server to close --- extension/src/main.ts | 54 +------------------------------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/extension/src/main.ts b/extension/src/main.ts index af5a287..d358aa0 100644 --- a/extension/src/main.ts +++ b/extension/src/main.ts @@ -132,61 +132,9 @@ 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 { +export function deactivate(): Promise { const promises: Thenable[] = []; if (defaultClient) { promises.push(defaultClient.stop()); From 733858f98831426e9b84cd084a2e4ad852299fdd Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sun, 1 Dec 2024 10:37:34 +0100 Subject: [PATCH 3/8] Use a debugging flag that doesn't keep the process running on exit See https://github.com/dart-lang/sdk/issues/24352 --- docs/contributing/testing-and-debugging.md | 2 +- extension/src/server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing/testing-and-debugging.md b/docs/contributing/testing-and-debugging.md index 00b7930..476579c 100644 --- a/docs/contributing/testing-and-debugging.md +++ b/docs/contributing/testing-and-debugging.md @@ -25,7 +25,7 @@ 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. +In this configuration, the client has run `dart run --enable-vm-service` in the local `sass_language_server` package. You can now use [Dart DevTools](https://dart.dev/tools/dart-devtools) to debug the language server. To find the link to open Dart DevTools, use the `[Extension Development Host]`. diff --git a/extension/src/server.ts b/extension/src/server.ts index 3983194..ab2630c 100644 --- a/extension/src/server.ts +++ b/extension/src/server.ts @@ -46,7 +46,7 @@ export async function createServerOptions( args: [ 'run', // '--pause-isolates-on-start', // Uncomment this to debug issues during startup and initial scan - '--observe', + '--enable-vm-service', 'sass_language_server', '--loglevel=debug', ], From 142352e6efd89aa90a35ef67f6eafcfe2bedb508 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sun, 1 Dec 2024 15:21:47 +0100 Subject: [PATCH 4/8] Hover --- .../lib/src/language_server.dart | 26 +- .../configuration/language_configuration.dart | 21 +- .../find_references_feature.dart | 5 +- .../go_to_definition_feature.dart | 2 +- .../lib/src/features/hover/hover_feature.dart | 414 ++++++++++++++++++ .../lib/src/features/language_feature.dart | 77 ++++ .../src/features/node_at_offset_visitor.dart | 32 +- .../features/selector_at_offset_visitor.dart | 101 +++++ .../lib/src/language_services.dart | 7 + .../lib/src/sass/sass_module_function.dart | 2 - .../lib/src/sass/sass_module_variable.dart | 3 +- .../lib/src/utils/string_utils.dart | 28 ++ .../test/features/hover/hover_test.dart | 411 +++++++++++++++++ .../test/utils/string_utils_test.dart | 22 + 14 files changed, 1141 insertions(+), 10 deletions(-) create mode 100644 pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart create mode 100644 pkgs/sass_language_services/lib/src/features/selector_at_offset_visitor.dart create mode 100644 pkgs/sass_language_services/lib/src/utils/string_utils.dart create mode 100644 pkgs/sass_language_services/test/features/hover/hover_test.dart create mode 100644 pkgs/sass_language_services/test/utils/string_utils_test.dart diff --git a/pkgs/sass_language_server/lib/src/language_server.dart b/pkgs/sass_language_server/lib/src/language_server.dart index 3d98f68..b5b00eb 100644 --- a/pkgs/sass_language_server/lib/src/language_server.dart +++ b/pkgs/sass_language_server/lib/src/language_server.dart @@ -152,7 +152,9 @@ class LanguageServer { _log.debug('workspace root $_workspaceRoot'); _ls = LanguageServices( - clientCapabilities: _clientCapabilities, fs: fileSystemProvider); + clientCapabilities: _clientCapabilities, + fs: fileSystemProvider, + ); var serverCapabilities = ServerCapabilities( definitionProvider: Either2.t1(true), @@ -160,6 +162,7 @@ class LanguageServer { documentLinkProvider: DocumentLinkOptions(resolveProvider: false), documentSymbolProvider: Either2.t1(true), foldingRangeProvider: Either3.t1(true), + hoverProvider: Either2.t1(true), referencesProvider: Either2.t1(true), renameProvider: Either2.t2(RenameOptions(prepareProvider: true)), selectionRangeProvider: Either3.t1(true), @@ -324,6 +327,27 @@ class LanguageServer { _connection.peer .registerMethod('textDocument/documentSymbol', onDocumentSymbol); + _connection.onHover((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { + // TODO: Would like to return null instead of empty content. + return Hover(contents: Either2.t2("")); + } + + var configuration = _getLanguageConfiguration(document); + if (configuration.hover.enabled) { + var result = await _ls.hover(document, params.position); + return result ?? Hover(contents: Either2.t2("")); + } else { + return Hover(contents: Either2.t2("")); + } + } on Exception catch (e) { + _log.debug(e.toString()); + return Hover(contents: Either2.t2("")); + } + }); + _connection.onReferences((params) async { try { var document = _documents.get(params.textDocument.uri); 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 bb30fe1..03f4aa8 100644 --- a/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart +++ b/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart @@ -1,15 +1,27 @@ class FeatureConfiguration { - late final bool enabled; + final bool enabled; FeatureConfiguration({required this.enabled}); } +class HoverConfiguration extends FeatureConfiguration { + final bool documentation; + final bool references; + + HoverConfiguration({ + required super.enabled, + required this.documentation, + required this.references, + }); +} + class LanguageConfiguration { late final FeatureConfiguration definition; late final FeatureConfiguration documentSymbols; late final FeatureConfiguration documentLinks; late final FeatureConfiguration foldingRanges; late final FeatureConfiguration highlights; + late final HoverConfiguration hover; late final FeatureConfiguration references; late final FeatureConfiguration rename; late final FeatureConfiguration selectionRanges; @@ -26,6 +38,13 @@ class LanguageConfiguration { enabled: config?['foldingRanges']?['enabled'] as bool? ?? true); highlights = FeatureConfiguration( enabled: config?['highlights']?['enabled'] as bool? ?? true); + + hover = HoverConfiguration( + enabled: config?['hover']?['enabled'] as bool? ?? true, + documentation: config?['hover']?['documentation'] as bool? ?? true, + references: config?['hover']?['references'] as bool? ?? true, + ); + references = FeatureConfiguration( enabled: config?['references']?['enabled'] as bool? ?? true); rename = FeatureConfiguration( diff --git a/pkgs/sass_language_services/lib/src/features/find_references/find_references_feature.dart b/pkgs/sass_language_services/lib/src/features/find_references/find_references_feature.dart index 5f29f1c..b069539 100644 --- a/pkgs/sass_language_services/lib/src/features/find_references/find_references_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/find_references/find_references_feature.dart @@ -9,6 +9,8 @@ import '../go_to_definition/definition.dart'; import 'reference.dart'; class FindReferencesFeature extends GoToDefinitionFeature { + final _sassData = SassData(); + FindReferencesFeature({required super.ls}); Future> findReferences(TextDocument document, @@ -29,8 +31,7 @@ class FindReferencesFeature extends GoToDefinitionFeature { String? builtin; if (definition.location == null) { // If we don't have a location we might be dealing with a built-in. - var sassData = SassData(); - for (var module in sassData.modules) { + for (var module in _sassData.modules) { for (var function in module.functions) { if (function.name == definition.name) { builtin = function.name; diff --git a/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart b/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart index f6dafcf..478f02f 100644 --- a/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart @@ -123,7 +123,7 @@ class GoToDefinitionFeature extends LanguageFeature { }) async { for (var kind in kinds) { // `@forward` may add a prefix to [name], - // but we're comparing it to symbols without that prefix. + // but in [document] the symbols are without that prefix. var unprefixedName = kind == ReferenceKind.function || kind == ReferenceKind.mixin || kind == ReferenceKind.variable diff --git a/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart b/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart new file mode 100644 index 0000000..49c25d9 --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart @@ -0,0 +1,414 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_api/sass_api.dart' as sass; +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:sass_language_services/src/utils/sass_lsp_utils.dart'; +import 'package:sass_language_services/src/utils/string_utils.dart'; + +import '../../css/css_data.dart'; +import '../../sass/sass_data.dart'; +import '../go_to_definition/go_to_definition_feature.dart'; +import '../node_at_offset_visitor.dart'; + +class HoverFeature extends GoToDefinitionFeature { + final _cssData = CssData(); + final _sassData = SassData(); + + HoverFeature({required super.ls}); + + bool _supportsMarkdown() => + ls.clientCapabilities.textDocument?.hover?.contentFormat + ?.any((f) => f == lsp.MarkupKind.Markdown) == + true || + ls.clientCapabilities.general?.markdown != null; + + Future doHover( + TextDocument document, lsp.Position position) async { + var stylesheet = ls.parseStylesheet(document); + var offset = document.offsetAt(position); + var path = getNodePathAtOffset(stylesheet, offset); + + lsp.Hover? hover; + + for (var i = 0; i < path.length; i++) { + var node = path.elementAt(i); + if (node is sass.SimpleSelector) { + return _selectorHover(path, i); + } else if (node is sass.Declaration) { + hover = _declarationHover(node); + } else if (node is sass.VariableExpression) { + hover = await _variableHover(node, document, position); + } else if (node is sass.FunctionExpression) { + hover = await _functionHover(node, document, position); + } else if (node is sass.IncludeRule) { + hover = await _mixinHover(node, document, position); + } + } + + return hover; + } + + lsp.Hover _selectorHover(List path, int index) { + var (selector, specificity) = _getSelectorHoverValue(path, index); + + if (_supportsMarkdown()) { + var contents = _asMarkdown('''```scss +$selector +``` + +[Specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity): ${readableSpecificity(specificity)} +'''); + return lsp.Hover(contents: contents); + } else { + var contents = _asPlaintext(''' +$selector + +Specificity: ${readableSpecificity(specificity)} +'''); + return lsp.Hover(contents: contents); + } + } + + /// Go back up the path and calculate a full selector string and specificity. + (String, int) _getSelectorHoverValue(List path, int index) { + var selector = ""; + var specificity = 0; + var pastImmediateStyleRule = false; + var lastWasParentSelector = false; + + for (var i = index; i >= 0; i--) { + var node = path.elementAt(i); + if (node is sass.ComplexSelector) { + var sel = node.span.text; + if (sel.startsWith('&')) { + lastWasParentSelector = true; + selector = "${sel.substring(1)} $selector"; + specificity += node.specificity; + } else { + if (lastWasParentSelector) { + selector = "$sel$selector"; + } else { + selector = "$sel $selector"; + } + specificity += node.specificity; + } + } else if (node is sass.StyleRule) { + // Don't add the direct parent StyleRule, + // otherwise we'll end up with the same selector twice. + if (!pastImmediateStyleRule) { + pastImmediateStyleRule = true; + continue; + } + + try { + if (node.selector.isPlain) { + var selectorList = sass.SelectorList.parse(node.selector.asPlain!); + + // Just pick the first one in case of a list. + var ruleSelector = selectorList.components.first; + var selectorString = ruleSelector.toString(); + + if (lastWasParentSelector) { + lastWasParentSelector = false; + + if (selectorString.startsWith('&')) { + lastWasParentSelector = true; + selector = "${selectorString.substring(1)}$selector"; + } else { + selector = "$selectorString$selector"; + } + // subtract one class worth that would otherwise be duplicated + specificity -= 1000; + } else { + if (selectorString.startsWith('&')) { + lastWasParentSelector = true; + selector = "${selectorString.substring(1)} $selector"; + } else { + selector = "$selectorString $selector"; + } + } + specificity += ruleSelector.specificity; + } + } on sass.SassFormatException catch (_) { + // Do nothing. + } + } + } + + return (selector.trim(), specificity); + } + + lsp.Either2 _asMarkdown(String content) { + return lsp.Either2.t1( + lsp.MarkupContent( + kind: lsp.MarkupKind.Markdown, + value: content, + ), + ); + } + + lsp.Either2 _asPlaintext(String content) { + return lsp.Either2.t1( + lsp.MarkupContent( + kind: lsp.MarkupKind.PlainText, + value: content, + ), + ); + } + + lsp.Hover? _declarationHover(sass.Declaration node) { + var data = _cssData.getProperty(node.name.toString()); + if (data == null) return null; + + var description = data.description; + var syntax = data.syntax; + + final re = RegExp(r'([A-Z]+)(\d+)?'); + const browserNames = { + "E": "Edge", + "FF": "Firefox", + "S": "Safari", + "C": "Chrome", + "IE": "IE", + "O": "Opera", + }; + + if (_supportsMarkdown()) { + var browsers = data.browsers?.map((b) { + var matches = re.firstMatch(b); + if (matches != null) { + var browser = matches.group(1); + var version = matches.group(2); + return "${browserNames[browser]} $version"; + } + return b; + }).join(', '); + + var references = data.references + ?.map((r) => '[${r.name}](${r.uri.toString()})') + .join('\n'); + var contents = _asMarkdown(''' +$description + +Syntax: $syntax + +$references + +$browsers +''' + .trim()); + return lsp.Hover(contents: contents); + } else { + var browsers = data.browsers?.map((b) { + var matches = re.firstMatch(b); + if (matches != null) { + var browser = matches.group(1); + var version = matches.group(2); + return "${browserNames[browser]} $version"; + } + return b; + }).join(', '); + + var contents = _asPlaintext(''' +$description + +Syntax: $syntax + +$browsers +'''); + return lsp.Hover(contents: contents); + } + } + + Future _variableHover(sass.VariableExpression node, + TextDocument document, lsp.Position position) async { + var name = node.nameSpan.text; + var range = toRange(node.nameSpan); + + var definition = await internalGoToDefinition(document, range.start); + if (definition == null) { + return null; + } + + if (definition.location == null) { + // If we don't have a location we are likely dealing with a built-in. + for (var module in _sassData.modules) { + for (var variable in module.variables) { + if (variable.name == definition.name) { + if (_supportsMarkdown()) { + var contents = _asMarkdown(''' +${variable.description} + +[Sass reference](${module.reference}#${variable.name}) +'''); + return lsp.Hover(contents: contents, range: range); + } else { + var contents = _asPlaintext(variable.description); + return lsp.Hover(contents: contents, range: range); + } + } + } + } + return null; + } + + var definitionDocument = ls.cache.getDocument(definition.location!.uri); + if (definitionDocument == null) { + return null; + } + + var definitionStylesheet = ls.parseStylesheet(definitionDocument); + var path = getNodePathAtOffset( + definitionStylesheet, + definitionDocument.offsetAt(definition.location!.range.start), + ); + + String? docComment; + String? rawValue; + for (var i = 0; i < path.length; i++) { + var node = path.elementAt(i); + if (node is sass.VariableDeclaration) { + docComment = node.comment?.docComment; + rawValue = node.expression.toString(); + break; + } + } + + String? resolvedValue; + if (rawValue != null && rawValue.contains(r'$')) { + resolvedValue = await findVariableValue( + definitionDocument, + definition.location!.range.start, + ); + } + + if (_supportsMarkdown()) { + var contents = _asMarkdown(''' +```${document.languageId} +${docComment != null ? '$docComment\n' : ''}$name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''} +``` +'''); + return lsp.Hover(contents: contents, range: range); + } else { + var contents = _asPlaintext(''' +${docComment != null ? '$docComment\n' : ''}$name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''} +'''); + return lsp.Hover(contents: contents, range: range); + } + } + + Future _functionHover(sass.FunctionExpression node, + TextDocument document, lsp.Position position) async { + var name = node.nameSpan.text; + var range = toRange(node.nameSpan); + + var definition = await internalGoToDefinition(document, range.start); + if (definition == null) { + return null; + } + + if (definition.location == null) { + // If we don't have a location we are likely dealing with a built-in. + for (var module in _sassData.modules) { + for (var function in module.functions) { + if (function.name == definition.name) { + if (_supportsMarkdown()) { + var contents = _asMarkdown(''' +${function.description} + +[Sass reference](${module.reference}#${function.name}) +'''); + return lsp.Hover(contents: contents, range: range); + } else { + var contents = _asPlaintext(function.description); + return lsp.Hover(contents: contents, range: range); + } + } + } + } + return null; + } + + var definitionDocument = ls.cache.getDocument(definition.location!.uri); + if (definitionDocument == null) { + return null; + } + + var definitionStylesheet = ls.parseStylesheet(definitionDocument); + var path = getNodePathAtOffset( + definitionStylesheet, + definitionDocument.offsetAt(definition.location!.range.start), + ); + + String? docComment; + String arguments = '()'; + for (var i = 0; i < path.length; i++) { + var node = path.elementAt(i); + if (node is sass.FunctionRule) { + docComment = node.comment?.docComment; + arguments = '(${node.arguments.toString()})'; + break; + } + } + + if (_supportsMarkdown()) { + var contents = _asMarkdown(''' +${docComment != null ? '$docComment\n' : ''}```${document.languageId} +@function $name$arguments +``` +'''); + return lsp.Hover(contents: contents, range: range); + } else { + var contents = _asPlaintext(''' +${docComment != null ? '$docComment\n' : ''}@function $name$arguments +'''); + return lsp.Hover(contents: contents, range: range); + } + } + + Future _mixinHover(sass.IncludeRule node, TextDocument document, + lsp.Position position) async { + var name = node.nameSpan.text; + var range = toRange(node.nameSpan); + + var definition = await goToDefinition(document, range.start); + if (definition == null) { + return null; + } + + var definitionDocument = ls.cache.getDocument(definition.uri); + if (definitionDocument == null) { + return null; + } + + var definitionStylesheet = ls.parseStylesheet(definitionDocument); + var path = getNodePathAtOffset( + definitionStylesheet, + definitionDocument.offsetAt(definition.range.start), + ); + + String? docComment; + String arguments = ''; + for (var i = 0; i < path.length; i++) { + var node = path.elementAt(i); + if (node is sass.MixinRule) { + docComment = node.comment?.docComment; + arguments = '(${node.arguments.toString()})'; + break; + } + } + + if (_supportsMarkdown()) { + var contents = _asMarkdown(''' +${docComment != null ? '$docComment\n' : ''}```${document.languageId} +@mixin $name$arguments +``` +'''); + return lsp.Hover(contents: contents, range: range); + } else { + var contents = _asPlaintext(''' +${docComment != null ? '$docComment\n' : ''}@mixin $name$arguments +'''); + return lsp.Hover(contents: contents, range: range); + } + } +} diff --git a/pkgs/sass_language_services/lib/src/features/language_feature.dart b/pkgs/sass_language_services/lib/src/features/language_feature.dart index 1494a59..b117841 100644 --- a/pkgs/sass_language_services/lib/src/features/language_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/language_feature.dart @@ -1,7 +1,11 @@ import 'dart:math'; +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_api/sass_api.dart' as sass; + import '../../sass_language_services.dart'; import '../utils/uri_utils.dart'; +import 'node_at_offset_visitor.dart'; class WorkspaceResult { final List? result; @@ -159,6 +163,79 @@ abstract class LanguageFeature { return WorkspaceResult(linksResult, visited); } + /// Returns the value of the variable at [position]. + /// + /// If the variable references another variable this method will find + /// that variable's definition and find the original value. + Future findVariableValue( + TextDocument document, lsp.Position position) async { + return _findValue(document, position); + } + + Future _findValue(TextDocument document, lsp.Position position, + {int depth = 0}) async { + const maxDepth = 10; + if (depth > maxDepth) { + return null; + } + + var stylesheet = ls.parseStylesheet(document); + var offset = document.offsetAt(position); + var visitor = NodeAtOffsetVisitor(offset); + var result = stylesheet.accept(visitor); + var variable = result ?? visitor.candidate; + + if (variable is sass.Expression) { + var isDeclaration = visitor.path.any( + (node) => node is sass.VariableDeclaration, + ); + if (isDeclaration) { + var referencesVariable = variable.toString().contains(r'$'); + if (referencesVariable) { + return _findValue( + document, + document.positionAt(variable.span.start.offset), + depth: depth + 1, + ); + } else { + return variable.toString(); + } + } else { + var valueString = variable.toString(); + var dollarIndex = valueString.indexOf(r'$'); + if (dollarIndex != -1) { + var definition = await ls.goToDefinition(document, position); + if (definition != null) { + var definitionDocument = ls.cache.getDocument(definition.uri); + if (definitionDocument == null) { + return null; + } + + if (definitionDocument.uri == document.uri) { + var definitionOffset = document.offsetAt(definition.range.start); + if (definitionOffset == variable.span.start.offset) { + // break early if we're looking up ourselves + return null; + } + } + + return _findValue( + definitionDocument, + definition.range.start, + depth: depth + 1, + ); + } else { + return null; + } + } else { + return valueString; + } + } + } else { + return null; + } + } + Future getTextDocument(Uri uri) async { var textDocument = ls.cache.getDocument(uri); if (textDocument == null) { 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 005a5bf..86eef21 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 @@ -1,4 +1,5 @@ import 'package:sass_api/sass_api.dart' as sass; +import 'package:sass_language_services/src/features/selector_at_offset_visitor.dart'; sass.AstNode? getNodeAtOffset(sass.ParentStatement node, int offset) { if (node.span.start.offset > offset || offset > node.span.end.offset) { @@ -11,11 +12,18 @@ sass.AstNode? getNodeAtOffset(sass.ParentStatement node, int offset) { return result ?? visitor.candidate; } +List getNodePathAtOffset(sass.ParentStatement node, int offset) { + var visitor = NodeAtOffsetVisitor(offset); + node.accept(visitor); + return visitor.path; +} + class NodeAtOffsetVisitor with sass.StatementSearchVisitor, sass.AstSearchVisitor { sass.AstNode? candidate; + final List path = []; final int _offset; /// Finds the node with the shortest span at [offset]. @@ -33,6 +41,7 @@ class NodeAtOffsetVisitor if (containsOffset) { if (candidate == null) { candidate = node; + path.add(node); processCandidate(node); } else { var nodeLength = nodeEndOffset - nodeStartOffset; @@ -42,6 +51,7 @@ class NodeAtOffsetVisitor candidateSpan.end.offset - candidateSpan.start.offset; if (nodeLength <= candidateLength) { candidate = node; + path.add(node); processCandidate(node); } } @@ -233,7 +243,27 @@ class NodeAtOffsetVisitor @override sass.AstNode? visitStyleRule(sass.StyleRule node) { - return _process(node) ?? super.visitStyleRule(node); + var result = _process(node); + if (result != null) return result; + + try { + if (node.selector.isPlain) { + var span = node.span; + var selectorList = sass.SelectorList.parse(node.selector.asPlain!); + var visitor = SelectorAtOffsetVisitor(_offset - span.start.offset); + var result = selectorList.accept(visitor) ?? visitor.candidate; + + if (result != null) { + candidate = result; + path.addAll(visitor.path); + return result; + } + } + } on sass.SassFormatException catch (_) { + // Do nothing. + } + + return super.visitStyleRule(node); } @override diff --git a/pkgs/sass_language_services/lib/src/features/selector_at_offset_visitor.dart b/pkgs/sass_language_services/lib/src/features/selector_at_offset_visitor.dart new file mode 100644 index 0000000..008433c --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/selector_at_offset_visitor.dart @@ -0,0 +1,101 @@ +import 'package:sass_api/sass_api.dart' as sass; + +class SelectorAtOffsetVisitor with sass.SelectorSearchVisitor { + sass.AstNode? candidate; + final List path = []; + final int _offset; + + /// Finds the node with the shortest span at [offset], + /// starting at 0 at the beginning of the selector. + SelectorAtOffsetVisitor(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; + var nodeEndOffset = nodeSpan.end.offset; + var containsOffset = nodeStartOffset <= _offset && nodeEndOffset >= _offset; + + if (containsOffset) { + if (candidate == null) { + candidate = node; + path.add(node); + processCandidate(node); + } else { + var nodeLength = nodeEndOffset - nodeStartOffset; + // store candidateSpan next to _candidate + var candidateSpan = candidate!.span; + var candidateLength = + candidateSpan.end.offset - candidateSpan.start.offset; + if (nodeLength <= candidateLength) { + candidate = node; + path.add(node); + processCandidate(node); + } + } + } + + if (nodeStartOffset > _offset) { + // return candidate; + } + + return null; + } + + @override + sass.AstNode? visitSelectorList(sass.SelectorList list) { + return _process(list) ?? super.visitSelectorList(list); + } + + @override + sass.AstNode? visitAttributeSelector(sass.AttributeSelector attribute) { + return _process(attribute) ?? super.visitAttributeSelector(attribute); + } + + @override + sass.AstNode? visitClassSelector(sass.ClassSelector klass) { + return _process(klass) ?? super.visitClassSelector(klass); + } + + @override + sass.AstNode? visitComplexSelector(sass.ComplexSelector complex) { + return _process(complex) ?? super.visitComplexSelector(complex); + } + + @override + sass.AstNode? visitCompoundSelector(sass.CompoundSelector compound) { + return _process(compound) ?? super.visitCompoundSelector(compound); + } + + @override + sass.AstNode? visitIDSelector(sass.IDSelector id) { + return _process(id) ?? super.visitIDSelector(id); + } + + @override + sass.AstNode? visitParentSelector(sass.ParentSelector placeholder) { + return _process(placeholder) ?? super.visitParentSelector(placeholder); + } + + @override + sass.AstNode? visitPlaceholderSelector(sass.PlaceholderSelector placeholder) { + return _process(placeholder) ?? super.visitPlaceholderSelector(placeholder); + } + + @override + sass.AstNode? visitPseudoSelector(sass.PseudoSelector pseudo) { + return _process(pseudo) ?? super.visitPseudoSelector(pseudo); + } + + @override + sass.AstNode? visitTypeSelector(sass.TypeSelector type) { + return _process(type) ?? super.visitTypeSelector(type); + } + + @override + sass.AstNode? visitUniversalSelector(sass.UniversalSelector universal) { + return _process(universal) ?? super.visitUniversalSelector(universal); + } +} diff --git a/pkgs/sass_language_services/lib/src/language_services.dart b/pkgs/sass_language_services/lib/src/language_services.dart index 132d30b..82be31e 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/folding_ranges/folding_ranges_feature.dart'; import 'package:sass_language_services/src/features/go_to_definition/go_to_definition_feature.dart'; +import 'package:sass_language_services/src/features/hover/hover_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'; @@ -27,6 +28,7 @@ class LanguageServices { late final FoldingRangesFeature _foldingRanges; late final FindReferencesFeature _findReferences; late final GoToDefinitionFeature _goToDefinition; + late final HoverFeature _hover; late final RenameFeature _rename; late final SelectionRangesFeature _selectionRanges; late final WorkspaceSymbolsFeature _workspaceSymbols; @@ -41,6 +43,7 @@ class LanguageServices { _findReferences = FindReferencesFeature(ls: this); _foldingRanges = FoldingRangesFeature(ls: this); _goToDefinition = GoToDefinitionFeature(ls: this); + _hover = HoverFeature(ls: this); _rename = RenameFeature(ls: this); _selectionRanges = SelectionRangesFeature(ls: this); _workspaceSymbols = WorkspaceSymbolsFeature(ls: this); @@ -87,6 +90,10 @@ class LanguageServices { return _goToDefinition.goToDefinition(document, position); } + Future hover(TextDocument document, lsp.Position position) { + return _hover.doHover(document, position); + } + sass.Stylesheet parseStylesheet(TextDocument document) { return cache.getStylesheet(document); } diff --git a/pkgs/sass_language_services/lib/src/sass/sass_module_function.dart b/pkgs/sass_language_services/lib/src/sass/sass_module_function.dart index 4ff4faf..dc8e88b 100644 --- a/pkgs/sass_language_services/lib/src/sass/sass_module_function.dart +++ b/pkgs/sass_language_services/lib/src/sass/sass_module_function.dart @@ -5,7 +5,6 @@ class SassModuleFunction { final String parameterSnippet; final String returns; - final Uri? reference; final String? deprecationMessage; SassModuleFunction(this.name, @@ -13,7 +12,6 @@ class SassModuleFunction { required this.signature, required this.parameterSnippet, required this.returns, - this.reference, this.deprecationMessage}); bool get isDeprecated => deprecationMessage != null; diff --git a/pkgs/sass_language_services/lib/src/sass/sass_module_variable.dart b/pkgs/sass_language_services/lib/src/sass/sass_module_variable.dart index b795702..c836a4e 100644 --- a/pkgs/sass_language_services/lib/src/sass/sass_module_variable.dart +++ b/pkgs/sass_language_services/lib/src/sass/sass_module_variable.dart @@ -2,11 +2,10 @@ class SassModuleVariable { final String name; final String description; - final Uri? reference; final String? deprecationMessage; SassModuleVariable(this.name, - {required this.description, this.reference, this.deprecationMessage}); + {required this.description, this.deprecationMessage}); bool get isDeprecated => deprecationMessage != null; } diff --git a/pkgs/sass_language_services/lib/src/utils/string_utils.dart b/pkgs/sass_language_services/lib/src/utils/string_utils.dart new file mode 100644 index 0000000..ab4a986 --- /dev/null +++ b/pkgs/sass_language_services/lib/src/utils/string_utils.dart @@ -0,0 +1,28 @@ +import 'dart:math'; + +String removeQuotes(String from) { + return from.replaceAll('"', '').replaceAll("'", ''); +} + +/// Takes a base 1000 specificity value from sass_api +/// and prints a more readable variant in the 0, 0, 0 +/// format. +String readableSpecificity(int specificity) { + var string = specificity.toString(); + string = string.padLeft(9, '0'); + + var firstPart = string.substring(0, 3); + var secondPart = string.substring(3, 6); + var thirdPart = string.substring(6); + + var first = int.parse(firstPart); + var second = int.parse(secondPart); + var third = int.parse(thirdPart); + + return '$first, $second, $third'; +} + +String getFileName(String uri) { + var lastSlash = uri.lastIndexOf("/"); + return lastSlash == -1 ? uri : uri.substring(max(0, lastSlash + 1)); +} diff --git a/pkgs/sass_language_services/test/features/hover/hover_test.dart b/pkgs/sass_language_services/test/features/hover/hover_test.dart new file mode 100644 index 0000000..6c21899 --- /dev/null +++ b/pkgs/sass_language_services/test/features/hover/hover_test.dart @@ -0,0 +1,411 @@ +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()); + +String? getContents(lsp.Hover? hover) { + if (hover == null) return null; + + var result = hover.contents.map((v) => v, (v) => v); + if (result is lsp.MarkupContent) { + return result.value; + } else { + return result as String; + } +} + +void main() { + group('CSS selectors', () { + setUp(() { + ls.cache.clear(); + }); + + test('nested element selector', () async { + var document = fs.createDocument(r''' +nav { + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li { + display: inline-block; + } + + a { + display: block; + padding: 6px 12px; + text-decoration: none; + } +} +'''); + var result = await ls.hover(document, at(line: 1, char: 5)); + + expect(result, isNotNull); + expect(getContents(result), contains('nav ul')); + expect(getContents(result), contains('0, 0, 2')); + }); + + test(':has(.foo)', () async { + var document = fs.createDocument(r''' +:has(.foo) { + margin: 0; + padding: 0; +} +'''); + var result = await ls.hover(document, at(line: 0, char: 2)); + + expect(result, isNotNull); + expect(getContents(result), contains(':has(.foo)')); + expect(getContents(result), contains('0, 1, 0')); + }); + + test(':has(#bar)', () async { + var document = fs.createDocument(r''' +:has(#bar) { + margin: 0; + padding: 0; +} +'''); + var result = await ls.hover(document, at(line: 0, char: 2)); + + expect(result, isNotNull); + expect(getContents(result), contains(':has(#bar)')); + expect(getContents(result), contains('1, 0, 0')); + }); + }); + + group('CSS properties', () { + setUp(() { + ls.cache.clear(); + }); + + test('property', () async { + var document = fs.createDocument(r''' +#bar { + margin: 0; + padding: 0; +} +'''); + var result = await ls.hover(document, at(line: 1, char: 3)); + + expect(result, isNotNull); + expect(getContents(result), contains('margin')); + }); + }); + + group('Parent selectors', () { + setUp(() { + ls.cache.clear(); + }); + + test('simple parent selector', () async { + var document = fs.createDocument(r''' +.button { + &--primary { + margin: 0; + padding: 0; + } +} +'''); + var result = await ls.hover(document, at(line: 1, char: 5)); + + expect(result, isNotNull); + expect(getContents(result), contains('.button--primary')); + expect(getContents(result), contains('0, 1, 0')); + }); + + test('parent selector with extras', () async { + var document = fs.createDocument(r''' +.button { + &--primary { + &::hover { + margin: 0; + padding: 0; + } + } +} +'''); + var result = await ls.hover(document, at(line: 2, char: 10)); + + expect(result, isNotNull); + expect(getContents(result), contains('.button--primary::hover')); + expect(getContents(result), contains('0, 1, 1')); + }); + + test('nested parent selector with extras', () async { + var document = fs.createDocument(r''' +.button { + &--primary::hover { + margin: 0; + padding: 0; + + .icon { + &--outline { + fill: #000; + } + } + } +} +'''); + var result = await ls.hover(document, at(line: 6, char: 10)); + + expect(result, isNotNull); + expect(getContents(result), + contains('.button--primary::hover .icon--outline')); + expect(getContents(result), contains('0, 2, 1')); + }); + }); + + group('Sass variables', () { + setUp(() { + ls.cache.clear(); + }); + + test('global variable with function expression value', () async { + var document = fs.createDocument(r''' +$_button-border-width: rem(1px); + +.button { + border-width: $_button-border-width; +} +'''); + + var result = await ls.hover(document, at(line: 3, char: 23)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'$_button-border-width: rem(1px)')); + }); + + test('variable behind a forward prefix', () async { + fs.createDocument(r''' +$border-width: rem(1px); +''', uri: 'button.scss'); + + fs.createDocument(r''' +@forward "button" as button-*; +''', uri: 'core.scss'); + + var document = fs.createDocument(r''' +@use "core"; + +.button { + border-width: core.$button-border-width; +} +'''); + + var result = await ls.hover(document, at(line: 3, char: 23)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'$button-border-width: rem(1px)')); + }); + }); + + group('Sass functions', () { + setUp(() { + ls.cache.clear(); + }); + + test('function with no arguments', () async { + var document = fs.createDocument(r''' +@function getPrimary() { + @return limegreen; +} + +.a { + color: getPrimary(); +} +'''); + + var result = await ls.hover(document, at(line: 5, char: 14)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'@function getPrimary()')); + }); + + test('function with arguments', () async { + var document = fs.createDocument(r''' +@function compare($a: 1, $b) { + @return $a > $b; +} + +@debug compare($b: 2); +'''); + + var result = await ls.hover(document, at(line: 4, char: 9)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'@function compare($a: 1, $b)')); + }); + + test('function from a different module', () async { + fs.createDocument(r''' +@function compare($a: 1, $b) { + @return $a > $b; +} +''', uri: 'core.scss'); + + var document = fs.createDocument(r''' +@use "core"; + +@debug core.compare($b: 2); +'''); + + var result = await ls.hover(document, at(line: 2, char: 9)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'@function compare($a: 1, $b)')); + }); + + test('function behind a prefix', () async { + fs.createDocument(r''' +@function compare($a: 1, $b) { + @return $a > $b; +} +''', uri: 'math.scss'); + + fs.createDocument(r''' +@forward "math" as math-*; +''', uri: 'core.scss'); + + var document = fs.createDocument(r''' +@use "core"; + +@debug core.math-compare($b: 2); +'''); + + var result = await ls.hover(document, at(line: 2, char: 19)); + + expect(result, isNotNull); + expect( + getContents(result), contains(r'@function math-compare($a: 1, $b)')); + }); + }); + + group('Sass mixins', () { + setUp(() { + ls.cache.clear(); + }); + + test('mixin with no arguments', () async { + var document = fs.createDocument(r''' +@mixin primary { + color: green; +} + +.a { + @include primary; +} +'''); + + var result = await ls.hover(document, at(line: 5, char: 14)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'@mixin primary')); + }); + + test('mixin with arguments', () async { + var document = fs.createDocument(r''' +@mixin theme($base: green) { + color: green; +} + +.a { + @include theme; +} +'''); + + var result = await ls.hover(document, at(line: 5, char: 14)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'@mixin theme($base: green)')); + }); + + test('mixin from a different module', () async { + fs.createDocument(r''' +@mixin theme($base: green) { + color: green; +} +''', uri: 'core.scss'); + + var document = fs.createDocument(r''' +@use "core"; + +.a { + @include core.theme; +} +'''); + + var result = await ls.hover(document, at(line: 3, char: 19)); + + expect(result, isNotNull); + expect(getContents(result), contains(r'@mixin theme($base: green)')); + }); + + test('mixin behind a prefix', () async { + fs.createDocument(r''' +@mixin color($base: green) { + color: green; +} +''', uri: 'theme.scss'); + + fs.createDocument(r''' +@forward "theme" as theme-*; +''', uri: 'core.scss'); + + var document = fs.createDocument(r''' +@use "core"; + +.a { + @include core.theme-color; +} +'''); + + var result = await ls.hover(document, at(line: 3, char: 19)); + + expect(result, isNotNull); + expect( + getContents(result), contains(r'@mixin theme-color($base: green)')); + }); + }); + + group('Sass built-ins', () { + setUp(() { + ls.cache.clear(); + }); + + test('math variable', () async { + var document = fs.createDocument(r''' +@use "sass:math"; + +@debug math.$pi; +'''); + + var result = await ls.hover(document, at(line: 2, char: 14)); + expect(result, isNotNull); + expect(getContents(result), contains(r'π')); + expect(getContents(result), contains(r'sass-lang.com')); + }); + + test('math function', () async { + var document = fs.createDocument(r''' +@use "sass:math"; + +@debug math.ceil(4); +'''); + + var result = await ls.hover(document, at(line: 2, char: 14)); + expect(result, isNotNull); + expect(getContents(result), + contains(r'Rounds up to the nearest whole number')); + expect(getContents(result), contains(r'sass-lang.com')); + }); + }); +} diff --git a/pkgs/sass_language_services/test/utils/string_utils_test.dart b/pkgs/sass_language_services/test/utils/string_utils_test.dart new file mode 100644 index 0000000..871d694 --- /dev/null +++ b/pkgs/sass_language_services/test/utils/string_utils_test.dart @@ -0,0 +1,22 @@ +import 'package:sass_language_services/src/utils/string_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('readable specificity', () { + test('single ID selector', () { + expect(readableSpecificity(1000000), equals('1, 0, 0')); + }); + + test('single class selector', () { + expect(readableSpecificity(1000), equals('0, 1, 0')); + }); + + test('single element selector', () { + expect(readableSpecificity(1), equals('0, 0, 1')); + }); + + test('element and class selector', () { + expect(readableSpecificity(1001), equals('0, 1, 1')); + }); + }); +} From d0e8857bb50454230ea9497ae44cf2ec5827756a Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sun, 15 Dec 2024 16:37:01 +0100 Subject: [PATCH 5/8] docs: attaching a debugger --- .vscode/launch.json | 9 +++- docs/contributing/testing-and-debugging.md | 59 +++++++++++++++------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index f794293..2de0a41 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,13 @@ "outFiles": ["${workspaceRoot}/extension/dist/main.js"], "autoAttachChildProcesses": true, "preLaunchTask": "npm: build - extension" - } + }, + { + "name": "Attach to language server", + "request": "attach", + "type": "dart", + "cwd": "pkgs/sass_language_server", + "vmServiceUri": "${command:dart.promptForVmService}" // Prompt for the VM Service URI + }, ] } diff --git a/docs/contributing/testing-and-debugging.md b/docs/contributing/testing-and-debugging.md index 476579c..1f0298a 100644 --- a/docs/contributing/testing-and-debugging.md +++ b/docs/contributing/testing-and-debugging.md @@ -13,42 +13,65 @@ 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 +### Find the link to Dart DevTools or VM service -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. +When debugging, the client runs [`dart run --enable-vm-service`](https://github.com/sass/dart-sass-language-server/blob/main/extension/src/server.ts#L49) +in the local `sass_language_server` package. -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. +Use the `[Extension Development Host]` window to find the link to open Dart DevTools or to [attach the debugger](#attach-to-language-server). -You should also turn off extensions like SCSS IntelliSense or Some Sass. +1. Open a CSS, SCSS or Sass file to activate the language server. +2. Open the Output pane (View -> Output in the menu). +3. Choose Sass in the dropdown to the top right of the Output pane. +4. Scroll to the top of the output. -### Open the Dart DevTools +You should see something similar to this. -In this configuration, the client has run `dart run --enable-vm-service` in the local `sass_language_server` package. You can now use [Dart DevTools](https://dart.dev/tools/dart-devtools) to debug the language server. +``` +The Dart VM service is listening on http://127.0.0.1:8181/SMIxtkPzlAY=/ +The Dart DevTools debugger and profiler is available at: http://127.0.0.1:8181/SMIxtkPzlAY=/devtools/?uri=ws://127.0.0.1:8181/SMIxtkPzlAY=/ws +``` -To find the link to open Dart DevTools, use the `[Extension Development Host]`. +Click the second link to open Dart DevTools, or copy the first link to [attach a debugger](#attach-to-language-server). -1. Open a CSS, SCSS or Sass file to activate the language server. -2. Open the Output pane (View -> Output in the menu). -3. In the dropdown in the top right, choose Sass from the list. +![screenshot showing the output pane and the dropdown with sass selected](https://github.com/user-attachments/assets/85839d2f-4305-4fb9-aeb0-d78f435e8b7d) + +### Attach to language server -You should see output similar to this. +The debugger in Dart DevTools is deprecated in favor the debugger that ships with [Dart for Visual Studio Code][vscodedart]. + +To start debugging in VS Code (provided you have the Dart extension): + +1. [Run the language server and extension](#run-the-language-extension-and-server) in debug mode. +2. [Find the link to the Dart VM](#find-the-link-to-dart-devtools-or-vm-service). + +You should see output similar to this in the `[Extension Development Host]`. ``` The Dart VM service is listening on http://127.0.0.1:8181/SMIxtkPzlAY=/ The Dart DevTools debugger and profiler is available at: http://127.0.0.1:8181/SMIxtkPzlAY=/devtools/?uri=ws://127.0.0.1:8181/SMIxtkPzlAY=/ws ``` -![screenshot showing the output pane and the dropdown with sass selected](https://github.com/user-attachments/assets/85839d2f-4305-4fb9-aeb0-d78f435e8b7d) +Copy the first link, then go back to the Run and debug window where you started the language server and extension. + +1. Click the Run and debug drop-down and run `Attach to language server`. +2. Paste the link you copied and hit Enter. + +Your debugger should be attached, allowing you to place breakpoints and step through code. + +### Test in VS Code without built-in SCSS features -Click the second link to open Dart DevTools. +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. -The Debugger tab has a File explorer in which you can find `package:sass_language_server`. Go to `src/language_server.dart` to find the request handlers for messages coming in from the client. +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. ## Debug unit tests -Assuming you installed [Dart for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code) you can debug individual unit tests by right-clicking the Run button in the editor gutter. +Assuming you installed [Dart for Visual Studio Code][vscodedart] you can debug individual unit tests by right-clicking the Run button in the editor gutter. Writing a test is often faster when debugging an issue with a specific language feature, and helps improve test coverage. @@ -71,3 +94,5 @@ test profile in the Run and Debug view in VS Code. ] } ``` + +[vscodedart]: https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code From 3263abffb669e89affc34f5c62a5a182d9384a04 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sun, 15 Dec 2024 17:11:24 +0100 Subject: [PATCH 6/8] New selector test case --- .../lib/src/features/hover/hover_feature.dart | 34 +++++++--- .../lib/src/sass/sass_data.dart | 62 +++++++++---------- .../test/features/hover/hover_test.dart | 22 +++++++ 3 files changed, 77 insertions(+), 41 deletions(-) diff --git a/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart b/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart index 49c25d9..e76e63b 100644 --- a/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart @@ -70,6 +70,7 @@ Specificity: ${readableSpecificity(specificity)} /// Go back up the path and calculate a full selector string and specificity. (String, int) _getSelectorHoverValue(List path, int index) { + var pre = ""; var selector = ""; var specificity = 0; var pastImmediateStyleRule = false; @@ -79,13 +80,19 @@ Specificity: ${readableSpecificity(specificity)} var node = path.elementAt(i); if (node is sass.ComplexSelector) { var sel = node.span.text; - if (sel.startsWith('&')) { + var parentSelectorIndex = sel.indexOf('&'); + if (parentSelectorIndex != -1) { lastWasParentSelector = true; - selector = "${sel.substring(1)} $selector"; + + pre = sel.substring(0, parentSelectorIndex); + var post = sel.substring(parentSelectorIndex + 1); + selector = "$post $selector"; specificity += node.specificity; } else { if (lastWasParentSelector) { - selector = "$sel$selector"; + lastWasParentSelector = false; + selector = "$pre$sel$selector"; + pre = ""; } else { selector = "$sel $selector"; } @@ -105,25 +112,32 @@ Specificity: ${readableSpecificity(specificity)} // Just pick the first one in case of a list. var ruleSelector = selectorList.components.first; - var selectorString = ruleSelector.toString(); + var sel = ruleSelector.toString(); if (lastWasParentSelector) { lastWasParentSelector = false; - if (selectorString.startsWith('&')) { + var parentSelectorIndex = sel.indexOf('&'); + if (parentSelectorIndex != -1) { lastWasParentSelector = true; - selector = "${selectorString.substring(1)}$selector"; + pre = "$pre ${sel.substring(0, parentSelectorIndex)}".trim(); + var post = sel.substring(parentSelectorIndex + 1); + selector = "$post$selector"; } else { - selector = "$selectorString$selector"; + selector = "$pre $sel$selector".trim(); + pre = ""; } // subtract one class worth that would otherwise be duplicated specificity -= 1000; } else { - if (selectorString.startsWith('&')) { + var parentSelectorIndex = sel.indexOf('&'); + if (parentSelectorIndex != -1) { lastWasParentSelector = true; - selector = "${selectorString.substring(1)} $selector"; + pre = sel.substring(0, parentSelectorIndex); + var post = sel.substring(parentSelectorIndex + 1); + selector = "$post $selector"; } else { - selector = "$selectorString $selector"; + selector = "$pre $sel $selector".trim(); } } specificity += ruleSelector.specificity; diff --git a/pkgs/sass_language_services/lib/src/sass/sass_data.dart b/pkgs/sass_language_services/lib/src/sass/sass_data.dart index 41879cf..88b4618 100644 --- a/pkgs/sass_language_services/lib/src/sass/sass_data.dart +++ b/pkgs/sass_language_services/lib/src/sass/sass_data.dart @@ -12,7 +12,7 @@ class SassData { SassModuleFunction( "adjust", description: - r"Increases or decreases one or more properties of `$color` by fixed amounts. All optional arguments must be numbers.\n\nIt's an error to specify an RGB property at the same time as an HSL property, or either of those at the same time as an HWB property.", + "Increases or decreases one or more properties of `\$color` by fixed amounts. All optional arguments must be numbers.\n\nIt's an error to specify an RGB property at the same time as an HSL property, or either of those at the same time as an HWB property.", signature: r"($color, $red: null, $green: null, $blue: null, $hue: null, $saturation: null, $lightness: null, $whiteness: null, $blackness: null, $alpha: null, $space: null)", parameterSnippet: r"${1:color}", @@ -29,7 +29,7 @@ class SassData { SassModuleFunction( "blackness", description: - r"Returns the HWB blackness of `$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#blackness) for how to migrate.", + "Returns the HWB blackness of `\$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#blackness) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -39,7 +39,7 @@ class SassData { SassModuleFunction( "blue", description: - r"Returns the blue channel of `$color` as a number between **0** and **255**.\n\nSee [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#blue) for how to migrate.", + "Returns the blue channel of `\$color` as a number between **0** and **255**.\n\nSee [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#blue) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -49,7 +49,7 @@ class SassData { SassModuleFunction( "change", description: - r"Sets one or more properties of `$color` to new values.\n\nIt's an error to specify an RGB property at the same time as an HSL property, or either of those at the same time as an HWB property.", + "Sets one or more properties of `\$color` to new values.\n\nIt's an error to specify an RGB property at the same time as an HSL property, or either of those at the same time as an HWB property.", signature: r"($color, $red: null, $green: null, $blue: null, $hue: null, $saturation: null, $lightness: null, $whiteness: null, $blackness: null, $alpha: null, $space: null)", parameterSnippet: r"${1:color}", @@ -81,7 +81,7 @@ class SassData { SassModuleFunction( "green", description: - r"Returns the green channel of `$color` as a number between **0** and **255**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#green) for how to migrate.", + "Returns the green channel of `\$color` as a number between **0** and **255**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#green) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -91,7 +91,7 @@ class SassData { SassModuleFunction( "hue", description: - r"Returns the hue of `$color` as a number between **0deg** and **360deg**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#hue) for how to migrate.", + "Returns the hue of `\$color` as a number between **0deg** and **360deg**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#hue) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -101,7 +101,7 @@ class SassData { SassModuleFunction( "hwb", description: - r"Returns a color with the given hue, whiteness, and blackness and the given alpha channel.\n\nThis function is [deprecated](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#css-color-functions-in-sass) in favor of the CSS [hwb function](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hwb).", + "Returns a color with the given hue, whiteness, and blackness and the given alpha channel.\n\nThis function is [deprecated](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#css-color-functions-in-sass) in favor of the CSS [hwb function](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hwb).", signature: r"($hue, $whiteness, $blackness, $alpha: 1)", parameterSnippet: r"(${1:hue}, ${2:whiteness}, ${3:blackness})", returns: "color", @@ -158,7 +158,7 @@ class SassData { SassModuleFunction( "lightness", description: - r"Returns the HSL lightness of `$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#lightness) for how to migrate.", + "Returns the HSL lightness of `\$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#lightness) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -176,7 +176,7 @@ class SassData { SassModuleFunction( "red", description: - r"Returns the red channel of `$color` as a number between **0** and **255**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#red) for how to migrate.", + "Returns the red channel of `\$color` as a number between **0** and **255**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#red) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -194,7 +194,7 @@ class SassData { SassModuleFunction( "saturation", description: - r"Returns the HSL saturation of `$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#saturation) for how to migrate.", + "Returns the HSL saturation of `\$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#saturation) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -204,7 +204,7 @@ class SassData { SassModuleFunction( "scale", description: - r"Fluidly scales one or more properties of `$color`. Each keyword argument must be a number between **-100%** and **100%**.\n\nIt's an error to specify an RGB property at the same time as an HSL property, or either of those at the same time as an HWB property.", + "Fluidly scales one or more properties of `\$color`. Each keyword argument must be a number between **-100%** and **100%**.\n\nIt's an error to specify an RGB property at the same time as an HSL property, or either of those at the same time as an HWB property.", signature: r"($color, $red: null, $green: null, $blue: null, $saturation: null, $lightness: null, $whiteness: null, $blackness: null, $alpha: null, $space: null)", parameterSnippet: r"${1:color}", @@ -237,7 +237,7 @@ class SassData { SassModuleFunction( "whiteness", description: - r"Returns the HWB whiteness of `$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#whiteness) for how to migrate.", + "Returns the HWB whiteness of `\$color` as a number between **0%** and **100%**.\n\nThis function is deprecated in favor of color-space-friendly functions. See [the announcement post](https://sass-lang.com/blog/wide-gamut-colors-in-sass/#deprecated-functions) and [documentation](https://sass-lang.com/documentation/modules/color/#whiteness) for how to migrate.", signature: r"($color)", parameterSnippet: r"${1:color}", returns: "number", @@ -261,7 +261,7 @@ class SassData { SassModuleFunction( "index", description: - r"Returns the index of `$value` in `$list`.\n\nNote that the index **1** indicates the first element of the list in Sass.", + "Returns the index of `\$value` in `\$list`.\n\nNote that the index **1** indicates the first element of the list in Sass.", signature: r"($list, $value)", parameterSnippet: r"${1:list}, ${2:value}", returns: "number", @@ -299,14 +299,14 @@ class SassData { ), SassModuleFunction("nth", description: - r"Returns the element of `$list` at index `$n`.\n\nIf `$n` is negative, it counts from the end of `$list`. Throws an error if there is no element at index `$n`.\n\nNote that the index **1** indicates the first element of the list in Sass.", + "Returns the element of `\$list` at index `\$n`.\n\nIf `\$n` is negative, it counts from the end of `\$list`. Throws an error if there is no element at index `\$n`.\n\nNote that the index **1** indicates the first element of the list in Sass.", signature: r"($list, $n)", parameterSnippet: r"${1:list}, ${2:number}", returns: "T"), SassModuleFunction( "set-nth", description: - r"Returns a copy of `$list` with the element at index `$n` replaced with `$value`.\n\nIf `$n` is negative, it counts from the end of `$list`. Throws an error if there is no existing element at index `$n`.\n\nNote that the index **1** indicates the first element of the list in Sass.", + "Returns a copy of `\$list` with the element at index `\$n` replaced with `\$value`.\n\nIf `\$n` is negative, it counts from the end of `\$list`. Throws an error if there is no existing element at index `\$n`.\n\nNote that the index **1** indicates the first element of the list in Sass.", signature: r"($list, $n, $value)", parameterSnippet: r"${1:list}, ${2:number}, ${3:value}", returns: "list", @@ -322,7 +322,7 @@ class SassData { SassModuleFunction( "zip", description: - r"Combines every list in $lists into a single list of sub-lists.\n\nEach element in the returned list contains all the elements at that position in $lists. The returned list is as long as the shortest list in $lists.\n\nThe returned list is always comma-separated and the sub-lists are always space-separated.", + "Combines every list in \$lists into a single list of sub-lists.\n\nEach element in the returned list contains all the elements at that position in \$lists. The returned list is as long as the shortest list in \$lists.\n\nThe returned list is always comma-separated and the sub-lists are always space-separated.", signature: r"($lists...)", parameterSnippet: r"${1:lists}", returns: "list", @@ -622,7 +622,7 @@ class SassData { SassModuleFunction( "call", description: - r"Invokes $function with $args and returns the result.\n\nThe $function should be a function returned by meta.get-function().", + "Invokes \$function with \$args and returns the result.\n\nThe \$function should be a function returned by meta.get-function().", signature: r"($function, $args...)", parameterSnippet: r"${1:function}, ${2:args}", returns: "T", @@ -630,7 +630,7 @@ class SassData { SassModuleFunction( "content-exists", description: - r"Returns whether the current mixin was passed a @content block.\n\nThrows if called outside of a mixin.", + "Returns whether the current mixin was passed a @content block.\n\nThrows if called outside of a mixin.", signature: r"()", parameterSnippet: r"", returns: "boolean", @@ -654,7 +654,7 @@ class SassData { SassModuleFunction( "get-function", description: - r"Returns the function named $name.\n\nIf $module is null, this returns the function named $name without a namespace. Otherwise, $module must be a string matching the namespace of a @use rule in the current file.\n\nBy default, this throws an error if $name doesn't refer to a Sass function. However, if $css is true, it instead returns a plain CSS function.\n\nThe returned function can be called using meta.call().", + "Returns the function named \$name.\n\nIf \$module is null, this returns the function named \$name without a namespace. Otherwise, \$module must be a string matching the namespace of a @use rule in the current file.\n\nBy default, this throws an error if \$name doesn't refer to a Sass function. However, if \$css is true, it instead returns a plain CSS function.\n\nThe returned function can be called using meta.call().", signature: r"($name, $css: false, $module: null)", parameterSnippet: r"${1:name}", returns: "function", @@ -662,7 +662,7 @@ class SassData { SassModuleFunction( "global-variable-exists", description: - r"Returns whether a global variable named $name (without the $) exists.\n\nIf $module is null, this returns whether a variable named $name without a namespace exists. Otherwise, $module must be a string matching the namespace of a @use rule in the current file, in which case this returns whether that module has a variable named $name.", + "Returns whether a global variable named \$name (without the \$) exists.\n\nIf \$module is null, this returns whether a variable named \$name without a namespace exists. Otherwise, \$module must be a string matching the namespace of a @use rule in the current file, in which case this returns whether that module has a variable named \$name.", signature: r"($name, $module: null)", parameterSnippet: r"${1:name}", returns: "boolean", @@ -670,7 +670,7 @@ class SassData { SassModuleFunction( "inspect", description: - r"Returns a string representation of $value.\n\nThis function is intended for debugging.", + "Returns a string representation of \$value.\n\nThis function is intended for debugging.", signature: r"($value)", parameterSnippet: r"${1:value}", returns: "string", @@ -678,7 +678,7 @@ class SassData { SassModuleFunction( "keywords", description: - r"Returns the keywords passed to a mixin or function that takes arbitrary arguments. The $args argument must be an argument list.\n\nThe keywords are returned as a map from argument names as unquoted strings (not including $) to the values of those arguments.", + "Returns the keywords passed to a mixin or function that takes arbitrary arguments. The \$args argument must be an argument list.\n\nThe keywords are returned as a map from argument names as unquoted strings (not including \$) to the values of those arguments.", signature: r"($args)", parameterSnippet: r"${1:args}", returns: "map", @@ -686,7 +686,7 @@ class SassData { SassModuleFunction( "mixin-exists", description: - r"Returns whether a mixin named $name exists.\n\nIf $module is null, this returns whether a mixin named $name without a namespace exists. Otherwise, $module must be a string matching the namespace of a @use rule in the current file, in which case this returns whether that module has a mixin named $name.", + "Returns whether a mixin named \$name exists.\n\nIf \$module is null, this returns whether a mixin named \$name without a namespace exists. Otherwise, \$module must be a string matching the namespace of a @use rule in the current file, in which case this returns whether that module has a mixin named \$name.", signature: r"($name, $module: null)", parameterSnippet: r"${1:name}", returns: "boolean", @@ -694,7 +694,7 @@ class SassData { SassModuleFunction( "module-functions", description: - r"Returns all the functions defined in a module, as a map from function names to function values.\n\nThe $module parameter must be a string matching the namespace of a @use rule in the current file.", + "Returns all the functions defined in a module, as a map from function names to function values.\n\nThe \$module parameter must be a string matching the namespace of a @use rule in the current file.", signature: r"($module)", parameterSnippet: r"${1:module}", returns: "map", @@ -702,7 +702,7 @@ class SassData { SassModuleFunction( "module-variables", description: - r"Returns all the variables defined in a module, as a map from variable names (without $) to the values of those variables.\n\nThe $module parameter must be a string matching the namespace of a @use rule in the current file.", + "Returns all the variables defined in a module, as a map from variable names (without \$) to the values of those variables.\n\nThe \$module parameter must be a string matching the namespace of a @use rule in the current file.", signature: r"($module)", parameterSnippet: r"${1:module}", returns: "map", @@ -739,7 +739,7 @@ class SassData { SassModuleFunction( "append", description: - r"Combines `$selectors` without descendant combinators — that is, without whitespace between them.\n\nIf any selector in `$selectors` is a selector list, each complex selector is combined separately.\n\nThe `$selectors` may contain placeholder selectors, but not parent selectors.", + "Combines `\$selectors` without descendant combinators — that is, without whitespace between them.\n\nIf any selector in `\$selectors` is a selector list, each complex selector is combined separately.\n\nThe `\$selectors` may contain placeholder selectors, but not parent selectors.", signature: r"($selectors...)", parameterSnippet: r"${1:selectors}", returns: "selector", @@ -785,7 +785,7 @@ class SassData { SassModuleFunction( "simple-selectors", description: - r"Returns a list of simple selectors in `$selector`.\n\n`$selector` must be a single string that contains a compound selector. This means it may not contain combinators (including spaces) or commas.\n\nThe returned list is comma-separated, and the simple selectors are unquoted strings.", + "Returns a list of simple selectors in `\$selector`.\n\n`\$selector` must be a single string that contains a compound selector. This means it may not contain combinators (including spaces) or commas.\n\nThe returned list is comma-separated, and the simple selectors are unquoted strings.", signature: r"($selector)", parameterSnippet: r"${1:selector}", returns: "list", @@ -806,7 +806,7 @@ class SassData { SassModuleFunction( "index", description: - r"Returns the first index of `$substring` in `$string`, or `null` if the substring is not found.\n\nNote that the index **1** indicates the first character of `$string` in Sass.", + "Returns the first index of `\$substring` in `\$string`, or `null` if the substring is not found.\n\nNote that the index **1** indicates the first character of `\$string` in Sass.", signature: r"($string, $substring)", parameterSnippet: r"${1:string}, ${2:substring}", returns: "number", @@ -814,7 +814,7 @@ class SassData { SassModuleFunction( "insert", description: - r"Returns a copy of `$string` with `$insert` inserted at `$index`.\n\nNote that the index **1** indicates the first character of `$string` in Sass.", + "Returns a copy of `\$string` with `\$insert` inserted at `\$index`.\n\nNote that the index **1** indicates the first character of `\$string` in Sass.", signature: r"($string, $insert, $index)", parameterSnippet: r"${1:string}, ${2:insert}, ${3:index}", returns: "string", @@ -829,7 +829,7 @@ class SassData { SassModuleFunction( "slice", description: - r"Returns the slice of `$string` starting at index `$start-at` and ending at index `$end-at` (both inclusive).\n\nNote that the index **1** indicates the first character of `$string` in Sass.", + "Returns the slice of `\$string` starting at index `\$start-at` and ending at index `\$end-at` (both inclusive).\n\nNote that the index **1** indicates the first character of `\$string` in Sass.", signature: r"($string, $start-at, $end-at: -1)", parameterSnippet: r"${1:string}, ${2:start-at}", returns: "string", @@ -837,7 +837,7 @@ class SassData { SassModuleFunction( "split", description: - r"Returns a bracketed, comma-separated list of substrings of `$string` that are separated by `$separator`. The `$separator`s aren't included in these substrings.\n\nIf `$limit` is a number 1 or higher, this splits on at most that many `$separator`s (and so returns at most `$limit` + 1 strings). The last substring contains the rest of the string, including any remaining `$separator`s.", + "Returns a bracketed, comma-separated list of substrings of `\$string` that are separated by `\$separator`. The `\$separator`s aren't included in these substrings.\n\nIf `\$limit` is a number 1 or higher, this splits on at most that many `\$separator`s (and so returns at most `\$limit` + 1 strings). The last substring contains the rest of the string, including any remaining `\$separator`s.", signature: r"($string, $separator, $limit: null)", parameterSnippet: r"${1:string}, ${2:separator}", returns: "list", diff --git a/pkgs/sass_language_services/test/features/hover/hover_test.dart b/pkgs/sass_language_services/test/features/hover/hover_test.dart index 6c21899..6eca29d 100644 --- a/pkgs/sass_language_services/test/features/hover/hover_test.dart +++ b/pkgs/sass_language_services/test/features/hover/hover_test.dart @@ -162,6 +162,28 @@ nav { contains('.button--primary::hover .icon--outline')); expect(getContents(result), contains('0, 2, 1')); }); + + test('parent selector not at the beginning of a selector', () async { + var document = fs.createDocument(r''' +.button { + &--primary, + &--secondary { + html[data-touch] &.button--pressed::before { + animation: fancy; + } + } +} +'''); + + var result = await ls.hover(document, at(line: 3, char: 24)); + + expect(result, isNotNull); + expect( + getContents(result), + contains( + 'html[data-touch] .button--primary.button--pressed::before')); + expect(getContents(result), contains('0, 3, 2')); + }); }); group('Sass variables', () { From 0eb04d44e2d4859fd7c0ee836d2c620a9d2e2ca6 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Mon, 16 Dec 2024 19:32:15 +0100 Subject: [PATCH 7/8] Docstring formating, Sass built-in hover --- .../go_to_definition_feature.dart | 3 ++ .../lib/src/features/hover/hover_feature.dart | 36 ++++++++----------- .../test/features/hover/hover_test.dart | 22 +++++++++--- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart b/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart index 478f02f..907cf8e 100644 --- a/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/go_to_definition/go_to_definition_feature.dart @@ -105,6 +105,9 @@ class GoToDefinitionFeature extends LanguageFeature { } } on StateError { return null; + } on UnsupportedError { + // The target URI scheme may be unsupported. + return null; } } diff --git a/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart b/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart index e76e63b..368fa32 100644 --- a/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart @@ -239,15 +239,11 @@ $browsers var range = toRange(node.nameSpan); var definition = await internalGoToDefinition(document, range.start); - if (definition == null) { - return null; - } - - if (definition.location == null) { + if (definition == null || definition.location == null) { // If we don't have a location we are likely dealing with a built-in. for (var module in _sassData.modules) { for (var variable in module.variables) { - if (variable.name == definition.name) { + if ('\$${variable.name}' == name) { if (_supportsMarkdown()) { var contents = _asMarkdown(''' ${variable.description} @@ -298,13 +294,13 @@ ${variable.description} if (_supportsMarkdown()) { var contents = _asMarkdown(''' ```${document.languageId} -${docComment != null ? '$docComment\n' : ''}$name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''} -``` +$name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''} +```${docComment != null ? '\n____\n${docComment.replaceAll('\n', '\n\n')}\n\n' : ''} '''); return lsp.Hover(contents: contents, range: range); } else { var contents = _asPlaintext(''' -${docComment != null ? '$docComment\n' : ''}$name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''} +$name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''}${docComment != null ? '\n\n$docComment' : ''} '''); return lsp.Hover(contents: contents, range: range); } @@ -316,15 +312,11 @@ ${docComment != null ? '$docComment\n' : ''}$name: ${resolvedValue ?? rawValue}$ var range = toRange(node.nameSpan); var definition = await internalGoToDefinition(document, range.start); - if (definition == null) { - return null; - } - - if (definition.location == null) { - // If we don't have a location we are likely dealing with a built-in. + if (definition == null || definition.location == null) { + // If we don't have a location we may be dealing with a built-in. for (var module in _sassData.modules) { for (var function in module.functions) { - if (function.name == definition.name) { + if (function.name == name) { if (_supportsMarkdown()) { var contents = _asMarkdown(''' ${function.description} @@ -366,14 +358,14 @@ ${function.description} if (_supportsMarkdown()) { var contents = _asMarkdown(''' -${docComment != null ? '$docComment\n' : ''}```${document.languageId} +```${document.languageId} @function $name$arguments -``` +```${docComment != null ? '\n____\n${docComment.replaceAll('\n', '\n\n')}\n\n' : ''} '''); return lsp.Hover(contents: contents, range: range); } else { var contents = _asPlaintext(''' -${docComment != null ? '$docComment\n' : ''}@function $name$arguments +@function $name$arguments${docComment != null ? '\n\n$docComment' : ''} '''); return lsp.Hover(contents: contents, range: range); } @@ -413,14 +405,14 @@ ${docComment != null ? '$docComment\n' : ''}@function $name$arguments if (_supportsMarkdown()) { var contents = _asMarkdown(''' -${docComment != null ? '$docComment\n' : ''}```${document.languageId} +```${document.languageId} @mixin $name$arguments -``` +```${docComment != null ? '\n____\n${docComment.replaceAll('\n', '\n\n')}\n\n' : ''} '''); return lsp.Hover(contents: contents, range: range); } else { var contents = _asPlaintext(''' -${docComment != null ? '$docComment\n' : ''}@mixin $name$arguments +@mixin $name$arguments${docComment != null ? '\n\n$docComment' : ''} '''); return lsp.Hover(contents: contents, range: range); } diff --git a/pkgs/sass_language_services/test/features/hover/hover_test.dart b/pkgs/sass_language_services/test/features/hover/hover_test.dart index 6eca29d..6df17ce 100644 --- a/pkgs/sass_language_services/test/features/hover/hover_test.dart +++ b/pkgs/sass_language_services/test/features/hover/hover_test.dart @@ -412,8 +412,8 @@ $border-width: rem(1px); var result = await ls.hover(document, at(line: 2, char: 14)); expect(result, isNotNull); - expect(getContents(result), contains(r'π')); - expect(getContents(result), contains(r'sass-lang.com')); + expect(getContents(result), contains('π')); + expect(getContents(result), contains('sass-lang.com')); }); test('math function', () async { @@ -426,8 +426,22 @@ $border-width: rem(1px); var result = await ls.hover(document, at(line: 2, char: 14)); expect(result, isNotNull); expect(getContents(result), - contains(r'Rounds up to the nearest whole number')); - expect(getContents(result), contains(r'sass-lang.com')); + contains('Rounds up to the nearest whole number')); + expect(getContents(result), contains('sass-lang.com')); + }); + + test('function as variable expression', () async { + var document = fs.createDocument(r''' +@use "sass:string"; + +$_id: string.unique-id(); +'''); + + var result = await ls.hover(document, at(line: 2, char: 14)); + expect(result, isNotNull); + expect(getContents(result), + contains('Returns a randomly-generated unquoted string')); + expect(getContents(result), contains('sass-lang.com')); }); }); } From c9d9686a78215aaf29858eec667a401006342903 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Mon, 16 Dec 2024 20:07:18 +0100 Subject: [PATCH 8/8] VSCode test --- .../test/electron/hover/fixtures/other.scss | 3 + .../test/electron/hover/fixtures/styles.sass | 9 +++ extension/test/electron/hover/hover.test.js | 69 +++++++++++++++++++ extension/test/electron/hover/index.js | 25 +++++++ 4 files changed, 106 insertions(+) create mode 100644 extension/test/electron/hover/fixtures/other.scss create mode 100644 extension/test/electron/hover/fixtures/styles.sass create mode 100644 extension/test/electron/hover/hover.test.js create mode 100644 extension/test/electron/hover/index.js diff --git a/extension/test/electron/hover/fixtures/other.scss b/extension/test/electron/hover/fixtures/other.scss new file mode 100644 index 0000000..bf86e12 --- /dev/null +++ b/extension/test/electron/hover/fixtures/other.scss @@ -0,0 +1,3 @@ +/// Docstring. +/// @type String +$from-other: 'hello'; diff --git a/extension/test/electron/hover/fixtures/styles.sass b/extension/test/electron/hover/fixtures/styles.sass new file mode 100644 index 0000000..d5acfc8 --- /dev/null +++ b/extension/test/electron/hover/fixtures/styles.sass @@ -0,0 +1,9 @@ +@use "sass:string" +@use "other" + +$_id: string.unique-id() +$_prefix: other.$from-other + +.card + .body:has(:not(.stuff)) + padding: 4px diff --git a/extension/test/electron/hover/hover.test.js b/extension/test/electron/hover/hover.test.js new file mode 100644 index 0000000..0dda92b --- /dev/null +++ b/extension/test/electron/hover/hover.test.js @@ -0,0 +1,69 @@ +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').Hover[]} hover + * @returns {string} + */ +function getHoverContents(hover) { + return hover + .flatMap((item) => { + return item.contents.map((content) => + typeof content === 'string' ? content : content.value + ); + }) + .join('\n'); +} + +/** + * @param {import('vscode').Uri} documentUri + * @param {import('vscode').Position} position + * @returns {Promise} + */ +async function hover(documentUri, position) { + const result = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + documentUri, + position + ); + return result; +} + +test('gets hover information from the same document', async () => { + const result = await hover(stylesUri, new vscode.Position(7, 10)); + + assert.match( + getHoverContents(result), + /\.card \.body:has\(:not\(\.stuff\)\)/ + ); +}); + +test('gets hover information from the workspace', async () => { + const result = await hover(stylesUri, new vscode.Position(4, 19)); + + assert.match(getHoverContents(result), /Docstring/); +}); + +test('gets hover information for Sass built-in', async () => { + const result = await hover(stylesUri, new vscode.Position(3, 14)); + + assert.match( + getHoverContents(result), + /Returns a randomly-generated unquoted string/ + ); +}); diff --git a/extension/test/electron/hover/index.js b/extension/test/electron/hover/index.js new file mode 100644 index 0000000..83212dd --- /dev/null +++ b/extension/test/electron/hover/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 };