diff --git a/pkgs/sass_language_server/lib/src/language_server.dart b/pkgs/sass_language_server/lib/src/language_server.dart index b5b00eb..514f4fb 100644 --- a/pkgs/sass_language_server/lib/src/language_server.dart +++ b/pkgs/sass_language_server/lib/src/language_server.dart @@ -157,6 +157,25 @@ class LanguageServer { ); var serverCapabilities = ServerCapabilities( + completionProvider: CompletionOptions( + resolveProvider: false, + triggerCharacters: [ + // For SassDoc annotation completion + "@", + "/", + // For @use completion + '"', + "'", + // For placeholder completion + "%", + // For namespaced completions + ".", + // For property values + ":", + // For custom properties + "-", + ], + ), definitionProvider: Either2.t1(true), documentHighlightProvider: Either2.t1(true), documentLinkProvider: DocumentLinkOptions(resolveProvider: false), @@ -327,6 +346,26 @@ class LanguageServer { _connection.peer .registerMethod('textDocument/documentSymbol', onDocumentSymbol); + _connection.onCompletion((params) async { + try { + var document = _documents.get(params.textDocument.uri); + if (document == null) { + return CompletionList(isIncomplete: true, items: []); + } + + var configuration = _getLanguageConfiguration(document); + if (configuration.completion.enabled) { + var result = await _ls.doComplete(document, params.position); + return result; + } else { + return CompletionList(isIncomplete: true, items: []); + } + } on Exception catch (e) { + _log.debug(e.toString()); + return CompletionList(isIncomplete: true, items: []); + } + }); + _connection.onHover((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 0381d21..a558074 100644 --- a/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart +++ b/pkgs/sass_language_services/lib/src/configuration/language_configuration.dart @@ -7,6 +7,25 @@ class FeatureConfiguration { FeatureConfiguration({required this.enabled}); } +enum MixinStyle { all, noBracket, bracket } + +class CompletionConfiguration extends FeatureConfiguration { + final bool completePropertyWithSemicolon; + final bool css; + final MixinStyle mixinStyle; + final bool suggestFromUseOnly; + final bool triggerPropertyValueCompletion; + + CompletionConfiguration({ + required super.enabled, + required this.completePropertyWithSemicolon, + required this.css, + required this.mixinStyle, + required this.suggestFromUseOnly, + required this.triggerPropertyValueCompletion, + }); +} + class HoverConfiguration extends FeatureConfiguration { final bool documentation; final bool references; @@ -25,6 +44,7 @@ class HoverConfiguration extends FeatureConfiguration { /// options to turn off features that cause duplicates or other /// interoperability errors. class LanguageConfiguration { + late final CompletionConfiguration completion; late final FeatureConfiguration definition; late final FeatureConfiguration documentSymbols; late final FeatureConfiguration documentLinks; @@ -37,6 +57,20 @@ class LanguageConfiguration { late final FeatureConfiguration workspaceSymbols; LanguageConfiguration.from(dynamic config) { + completion = CompletionConfiguration( + enabled: config?['completion']?['enabled'] as bool? ?? true, + completePropertyWithSemicolon: + config?['completion']?['completePropertyWithSemicolon'] as bool? ?? + true, + css: config?['completion']?['css'] as bool? ?? true, + mixinStyle: _toMixinStyle(config?['completion']?['mixinStyle']), + suggestFromUseOnly: + config?['completion']?['suggestFromUseOnly'] as bool? ?? true, + triggerPropertyValueCompletion: + config?['completion']?['triggerPropertyValueCompletion'] as bool? ?? + true, + ); + definition = FeatureConfiguration( enabled: config?['definition']?['enabled'] as bool? ?? true); documentSymbols = FeatureConfiguration( @@ -63,4 +97,17 @@ class LanguageConfiguration { workspaceSymbols = FeatureConfiguration( enabled: config?['workspaceSymbols']?['enabled'] as bool? ?? true); } + + MixinStyle _toMixinStyle(dynamic style) { + var styleString = style as String? ?? 'all'; + switch (styleString) { + case 'nobracket': + return MixinStyle.noBracket; + case 'bracket': + return MixinStyle.bracket; + case 'all': + default: + return MixinStyle.all; + } + } } diff --git a/pkgs/sass_language_services/lib/src/css/css_property.dart b/pkgs/sass_language_services/lib/src/css/css_property.dart index d261b99..417b55d 100644 --- a/pkgs/sass_language_services/lib/src/css/css_property.dart +++ b/pkgs/sass_language_services/lib/src/css/css_property.dart @@ -1,7 +1,20 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; + +import '../utils/sass_lsp_utils.dart'; import 'css_value.dart'; import 'entry_status.dart'; import 'reference.dart'; +final re = RegExp(r'([A-Z]+)(\d+)?'); +const browserNames = { + "E": "Edge", + "FF": "Firefox", + "S": "Safari", + "C": "Chrome", + "IE": "IE", + "O": "Opera", +}; + class CssProperty { final String name; final String? description; @@ -14,14 +27,65 @@ class CssProperty { final int? relevance; final String? atRule; - CssProperty(this.name, - {this.description, - this.browsers, - this.restrictions, - this.status, - this.syntax, - this.values, - this.references, - this.relevance, - this.atRule}); + CssProperty( + this.name, { + this.description, + this.browsers, + this.restrictions, + this.status, + this.syntax, + this.values, + this.references, + this.relevance, + this.atRule, + }); + + lsp.Either2 getPlaintextDescription() { + var browsersString = 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 + +$browsersString +'''); + return contents; + } + + lsp.Either2 getMarkdownDescription() { + var browsersString = 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 referencesString = references + ?.map((r) => '[${r.name}](${r.uri.toString()})') + .join('\n'); + + var contents = asMarkdown(''' +$description + +Syntax: $syntax + +$referencesString + +$browsersString +''' + .trim()); + return contents; + } } diff --git a/pkgs/sass_language_services/lib/src/features/completion/completion_context.dart b/pkgs/sass_language_services/lib/src/features/completion/completion_context.dart new file mode 100644 index 0000000..e5f6d50 --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/completion/completion_context.dart @@ -0,0 +1,27 @@ +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 '../../configuration/language_configuration.dart'; + +class CompletionContext { + final lsp.Position position; + final String currentWord; + final String lineBeforePosition; + final int offset; + final lsp.Range defaultReplaceRange; + final TextDocument document; + final sass.Stylesheet stylesheet; + final CompletionConfiguration configuration; + + CompletionContext({ + required this.offset, + required this.position, + required this.currentWord, + required this.defaultReplaceRange, + required this.document, + required this.stylesheet, + required this.configuration, + required this.lineBeforePosition, + }); +} diff --git a/pkgs/sass_language_services/lib/src/features/completion/completion_feature.dart b/pkgs/sass_language_services/lib/src/features/completion/completion_feature.dart new file mode 100644 index 0000000..92ff4d2 --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/completion/completion_feature.dart @@ -0,0 +1,270 @@ +import 'dart:math'; + +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/css/entry_status.dart'; + +import '../../utils/sass_lsp_utils.dart'; +import '../language_feature.dart'; +import '../node_at_offset_visitor.dart'; +import './completion_context.dart'; +import './completion_list.dart'; + +final triggerSuggestCommand = lsp.Command( + title: 'Suggest', + command: 'editor.action.triggerSuggest', +); + +// Sort string prefixes +const enums = ' '; +const normal = 'e'; +const vendorPrefix = 'o'; +const term = 'p'; +const variable = 'q'; + +class CompletionFeature extends LanguageFeature { + CompletionFeature({required super.ls}); + + Future doComplete( + TextDocument document, lsp.Position position) async { + var configuration = getLanguageConfiguration(document).completion; + + var offset = document.offsetAt(position); + var (lineBeforePosition, currentWord) = _getLineContext(document, offset); + + var defaultReplaceRange = lsp.Range( + start: lsp.Position( + line: position.line, + character: position.character - currentWord.length, + ), + end: position, + ); + + var result = CompletionList( + isIncomplete: false, + items: [], + itemDefaults: lsp.CompletionListItemDefaults( + editRange: lsp.Either2.t2(defaultReplaceRange), + ), + ); + + var stylesheet = ls.parseStylesheet(document); + var path = getNodePathAtOffset(stylesheet, offset); + + var context = CompletionContext( + offset: offset, + position: position, + currentWord: currentWord, + defaultReplaceRange: defaultReplaceRange, + document: document, + configuration: configuration, + stylesheet: stylesheet, + lineBeforePosition: lineBeforePosition, + ); + + for (var i = path.length - 1; i >= 0; i--) { + var node = path[i]; + if (node is sass.Declaration) { + _declarationCompletion(node, context, result); + } else if (node is sass.SimpleSelector) { + _selectorCompletion(node, context, result); + } else if (node is sass.Interpolation) { + var isExtendRule = false; + for (var j = i; j >= 0; j--) { + var parent = path[j]; + if (parent is sass.ExtendRule) { + isExtendRule = true; + break; + } + } + if (isExtendRule) { + _extendRuleCompletion(node, context, result); + } else { + _interpolationCompletion(node, context, result); + } + } else if (node is sass.ArgumentInvocation) { + _argumentInvocationCompletion(node, context, result); + } else if (node is sass.StyleRule) { + _styleRuleCompletion(node, context, result); + } else if (node is sass.PlaceholderSelector) { + _placeholderSelectorCompletion(node, context, result); + } else if (node is sass.VariableDeclaration) { + _variableDeclarationCompletion(node, context, result); + } else if (node is sass.FunctionRule) { + _functionRuleCompletion(node, context, result); + } else if (node is sass.MixinRule) { + _mixinRuleCompletion(node, context, result); + } else if (node is sass.SupportsRule) { + _supportsRuleCompletion(node, context, result); + } else if (node is sass.SupportsCondition) { + _supportsConditionCompletion(node, context, result); + } else if (node is sass.StringExpression) { + var isSassDependency = false; + var isImportRule = false; + for (var j = i; j >= 0; j--) { + var parent = path[j]; + if (parent is sass.SassDependency) { + isSassDependency = true; + break; + } else if (parent is sass.ImportRule) { + isImportRule = true; + break; + } + } + if (isSassDependency) { + await _sassDependencyCompletion(node, context, result); + } else if (isImportRule) { + await _importRuleCompletion(node, context, result); + } + } else if (node is sass.SilentComment) { + _commentCompletion(node, context, result); + } else { + continue; + } + + if (result.items.isNotEmpty || context.offset > node.span.start.offset) { + return _send(result); + } + } + + _stylesheetCompletion(context, result); + return _send(result); + } + + lsp.CompletionList _send(CompletionList result) { + return lsp.CompletionList( + isIncomplete: result.isIncomplete, + items: result.items, + itemDefaults: result.itemDefaults, + ); + } + + /// Get the current word and the contents of the line before [offset]. + (String, String) _getLineContext(TextDocument document, int offset) { + var text = document.getText(); + + // From offset, go back until hitting a newline + var i = offset - 1; + var linebreaks = '\n\r'.codeUnits; + while (i >= 0 && !linebreaks.contains(text.codeUnitAt(i))) { + i--; + } + var lineBeforePosition = text.substring(i + 1, offset); + + // From offset, go back until hitting a word delimiter + i = offset - 1; + var wordDelimiters = ' \t\n\r":[()]}/,\''.codeUnits; + while (i >= 0 && !wordDelimiters.contains(text.codeUnitAt(i))) { + i--; + } + var currentWord = text.substring(i + 1, offset); + + return (lineBeforePosition, currentWord); + } + + void _declarationCompletion( + sass.AstNode node, CompletionContext context, CompletionList result) { + for (var property in cssData.properties) { + var range = context.defaultReplaceRange; + var insertText = property.name; + var triggerSuggest = false; + + if (node is sass.Declaration) { + range = toRange(node.name.span); + if (!node.span.text.contains(':')) { + insertText += ': '; + triggerSuggest = true; + } + } else { + insertText += ': '; + triggerSuggest = true; + } + + var isDeprecated = property.status == EntryStatus.nonstandard || + property.status == EntryStatus.obsolete; + + if (property.restrictions == null) { + triggerSuggest = false; + } + + lsp.Command? command; + if (context.configuration.triggerPropertyValueCompletion && + triggerSuggest) { + command = triggerSuggestCommand; + } + + var relevance = 50; + if (property.relevance case var rel?) { + relevance = min(max(rel, 0), 99); + } + + var suffix = (255 - relevance).toRadixString(16); + var prefix = insertText.startsWith('-') ? vendorPrefix : normal; + var sortText = '${prefix}_$suffix'; + + var item = lsp.CompletionItem( + label: property.name, + documentation: supportsMarkdown() + ? property.getMarkdownDescription() + : property.getPlaintextDescription(), + tags: isDeprecated ? [lsp.CompletionItemTag.Deprecated] : [], + textEdit: lsp.Either2.t2( + lsp.TextEdit(range: range, newText: insertText), + ), + insertTextFormat: lsp.InsertTextFormat.Snippet, + sortText: sortText, + kind: lsp.CompletionItemKind.Property, + command: command, + ); + result.items.add(item); + } + } + + void _interpolationCompletion(sass.Interpolation node, + CompletionContext context, CompletionList result) {} + + void _extendRuleCompletion(sass.Interpolation node, CompletionContext context, + CompletionList result) {} + + void _selectorCompletion(sass.SimpleSelector node, CompletionContext context, + CompletionList result) {} + + void _argumentInvocationCompletion(sass.ArgumentInvocation node, + CompletionContext context, CompletionList result) {} + + void _styleRuleCompletion( + sass.StyleRule node, CompletionContext context, CompletionList result) { + _declarationCompletion(node, context, result); + } + + void _variableDeclarationCompletion(sass.VariableDeclaration node, + CompletionContext context, CompletionList result) {} + + void _functionRuleCompletion(sass.FunctionRule node, + CompletionContext context, CompletionList result) {} + + void _mixinRuleCompletion( + sass.MixinRule node, CompletionContext context, CompletionList result) {} + + void _supportsRuleCompletion(sass.SupportsRule node, + CompletionContext context, CompletionList result) {} + + void _supportsConditionCompletion(sass.SupportsCondition node, + CompletionContext context, CompletionList result) {} + + Future _sassDependencyCompletion(sass.StringExpression node, + CompletionContext context, CompletionList result) async {} + + Future _importRuleCompletion(sass.StringExpression node, + CompletionContext context, CompletionList result) async {} + + void _stylesheetCompletion( + CompletionContext context, CompletionList result) {} + + void _commentCompletion(sass.SilentComment node, CompletionContext context, + CompletionList result) {} + + void _placeholderSelectorCompletion(sass.PlaceholderSelector node, + CompletionContext context, CompletionList result) {} +} diff --git a/pkgs/sass_language_services/lib/src/features/completion/completion_list.dart b/pkgs/sass_language_services/lib/src/features/completion/completion_list.dart new file mode 100644 index 0000000..5bf6663 --- /dev/null +++ b/pkgs/sass_language_services/lib/src/features/completion/completion_list.dart @@ -0,0 +1,14 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; + +/// A mutable variant of [lsp.CompletionList]. +class CompletionList { + bool isIncomplete; + List items; + lsp.CompletionListItemDefaults itemDefaults; + + CompletionList({ + required this.isIncomplete, + required this.itemDefaults, + required this.items, + }); +} 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 368fa32..c692582 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 @@ -4,23 +4,12 @@ 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); @@ -50,8 +39,8 @@ class HoverFeature extends GoToDefinitionFeature { lsp.Hover _selectorHover(List path, int index) { var (selector, specificity) = _getSelectorHoverValue(path, index); - if (_supportsMarkdown()) { - var contents = _asMarkdown('''```scss + if (supportsMarkdown()) { + var contents = asMarkdown('''```scss $selector ``` @@ -59,7 +48,7 @@ $selector '''); return lsp.Hover(contents: contents); } else { - var contents = _asPlaintext(''' + var contents = asPlaintext(''' $selector Specificity: ${readableSpecificity(specificity)} @@ -151,26 +140,8 @@ Specificity: ${readableSpecificity(specificity)} 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()); + var data = cssData.getProperty(node.name.toString()); if (data == null) return null; var description = data.description; @@ -186,7 +157,7 @@ Specificity: ${readableSpecificity(specificity)} "O": "Opera", }; - if (_supportsMarkdown()) { + if (supportsMarkdown()) { var browsers = data.browsers?.map((b) { var matches = re.firstMatch(b); if (matches != null) { @@ -200,7 +171,7 @@ Specificity: ${readableSpecificity(specificity)} var references = data.references ?.map((r) => '[${r.name}](${r.uri.toString()})') .join('\n'); - var contents = _asMarkdown(''' + var contents = asMarkdown(''' $description Syntax: $syntax @@ -222,7 +193,7 @@ $browsers return b; }).join(', '); - var contents = _asPlaintext(''' + var contents = asPlaintext(''' $description Syntax: $syntax @@ -241,18 +212,18 @@ $browsers var definition = await internalGoToDefinition(document, range.start); 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 module in sassData.modules) { for (var variable in module.variables) { if ('\$${variable.name}' == name) { - if (_supportsMarkdown()) { - var contents = _asMarkdown(''' + 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); + var contents = asPlaintext(variable.description); return lsp.Hover(contents: contents, range: range); } } @@ -291,15 +262,15 @@ ${variable.description} ); } - if (_supportsMarkdown()) { - var contents = _asMarkdown(''' + if (supportsMarkdown()) { + var contents = asMarkdown(''' ```${document.languageId} $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(''' + var contents = asPlaintext(''' $name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''}${docComment != null ? '\n\n$docComment' : ''} '''); return lsp.Hover(contents: contents, range: range); @@ -314,18 +285,18 @@ $name: ${resolvedValue ?? rawValue}${document.languageId != 'sass' ? ';' : ''}${ var definition = await internalGoToDefinition(document, range.start); 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 module in sassData.modules) { for (var function in module.functions) { if (function.name == name) { - if (_supportsMarkdown()) { - var contents = _asMarkdown(''' + 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); + var contents = asPlaintext(function.description); return lsp.Hover(contents: contents, range: range); } } @@ -356,15 +327,15 @@ ${function.description} } } - if (_supportsMarkdown()) { - var contents = _asMarkdown(''' + if (supportsMarkdown()) { + var contents = asMarkdown(''' ```${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(''' + var contents = asPlaintext(''' @function $name$arguments${docComment != null ? '\n\n$docComment' : ''} '''); return lsp.Hover(contents: contents, range: range); @@ -403,15 +374,15 @@ ${function.description} } } - if (_supportsMarkdown()) { - var contents = _asMarkdown(''' + if (supportsMarkdown()) { + var contents = asMarkdown(''' ```${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(''' + var contents = asPlaintext(''' @mixin $name$arguments${docComment != null ? '\n\n$docComment' : ''} '''); 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 b117841..ab8448e 100644 --- a/pkgs/sass_language_services/lib/src/features/language_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/language_feature.dart @@ -4,6 +4,8 @@ import 'package:lsp_server/lsp_server.dart' as lsp; import 'package:sass_api/sass_api.dart' as sass; import '../../sass_language_services.dart'; +import '../css/css_data.dart'; +import '../sass/sass_data.dart'; import '../utils/uri_utils.dart'; import 'node_at_offset_visitor.dart'; @@ -15,8 +17,17 @@ class WorkspaceResult { } abstract class LanguageFeature { + final cssData = CssData(); + final sassData = SassData(); + late final LanguageServices ls; + bool supportsMarkdown() => + ls.clientCapabilities.textDocument?.hover?.contentFormat + ?.any((f) => f == lsp.MarkupKind.Markdown) == + true || + ls.clientCapabilities.general?.markdown != null; + LanguageFeature({required this.ls}); /// Helper to do some kind of lookup for the import tree of [initialDocument]. diff --git a/pkgs/sass_language_services/lib/src/language_services.dart b/pkgs/sass_language_services/lib/src/language_services.dart index aaae649..5763c0c 100644 --- a/pkgs/sass_language_services/lib/src/language_services.dart +++ b/pkgs/sass_language_services/lib/src/language_services.dart @@ -1,6 +1,7 @@ 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/features/completion/completion_feature.dart'; import 'package:sass_language_services/src/features/document_highlights/document_highlights_feature.dart'; 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'; @@ -27,6 +28,7 @@ class LanguageServices { LanguageServerConfiguration configuration = LanguageServerConfiguration.create(null); + late final CompletionFeature _completion; late final DocumentHighlightsFeature _documentHighlights; late final DocumentLinksFeature _documentLinks; late final DocumentSymbolsFeature _documentSymbols; @@ -42,6 +44,7 @@ class LanguageServices { required this.clientCapabilities, required this.fs, }) : cache = LanguageServicesCache() { + _completion = CompletionFeature(ls: this); _documentHighlights = DocumentHighlightsFeature(ls: this); _documentLinks = DocumentLinksFeature(ls: this); _documentSymbols = DocumentSymbolsFeature(ls: this); @@ -58,6 +61,14 @@ class LanguageServices { this.configuration = configuration; } + /// Get a response for the [completion proposal](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_completion) request. + /// + /// Editors use this response to show relevant suggestions when typing. + Future doComplete( + TextDocument document, lsp.Position position) { + return _completion.doComplete(document, position); + } + /// Get a response for the [document highlights](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_documentHighlight) request. /// /// Editors use this response to highlight occurences diff --git a/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart b/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart index 1956230..023dace 100644 --- a/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart +++ b/pkgs/sass_language_services/lib/src/utils/sass_lsp_utils.dart @@ -63,3 +63,21 @@ bool isSameRange(lsp.Range a, lsp.Range b) { a.end.line == b.end.line && a.end.character == b.end.character; } + +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, + ), + ); +} diff --git a/pkgs/sass_language_services/test/features/completion/completion_test.dart b/pkgs/sass_language_services/test/features/completion/completion_test.dart new file mode 100644 index 0000000..0cc470f --- /dev/null +++ b/pkgs/sass_language_services/test/features/completion/completion_test.dart @@ -0,0 +1,121 @@ +import 'package:lsp_server/lsp_server.dart' as lsp; +import 'package:sass_language_services/sass_language_services.dart'; +import 'package:test/test.dart'; + +import '../../memory_file_system.dart'; +import '../../position_utils.dart'; +import '../../test_client_capabilities.dart'; + +final fs = MemoryFileSystem(); +final ls = LanguageServices(fs: fs, clientCapabilities: getCapabilities()); + +void hasEntry(lsp.CompletionList result, String label) { + expect( + result.items.any((i) => i.label == label), + isTrue, + reason: 'Expected to find an entry with the label $label', + ); +} + +void hasNoEntry(lsp.CompletionList result, String label) { + expect( + result.items.every((i) => i.label != label), + isTrue, + reason: 'Did not expect to find an entry with the label $label', + ); +} + +void main() { + group('CSS declarations', () { + setUp(() { + ls.cache.clear(); + }); + + test('in an empty style rule', () async { + var document = fs.createDocument(r''' +.a { } +'''); + var result = await ls.doComplete(document, at(line: 0, char: 5)); + + expect(result.items, isNotEmpty); + hasEntry(result, 'display'); + hasEntry(result, 'font-size'); + }); + + test('in an empty style rule for indented', () async { + var document = fs.createDocument(r''' +.a + // Here to stop removing trailing whitespace +''', uri: 'indented.sass'); + var result = await ls.doComplete(document, at(line: 1, char: 2)); + + expect(result.items, isNotEmpty); + hasEntry(result, 'display'); + hasEntry(result, 'font-size'); + }); + + test('in style rule with other declarations', () async { + var document = fs.createDocument(r''' +.a + display: block + // Here to stop removing trailing whitespace +''', uri: 'indented.sass'); + var result = await ls.doComplete(document, at(line: 2, char: 2)); + + expect(result.items, isNotEmpty); + hasEntry(result, 'display'); + hasEntry(result, 'font-size'); + }); + + test('not outside of a style rule', () async { + var document = fs.createDocument(r''' + +.a { } +'''); + var result = await ls.doComplete(document, at(line: 0, char: 0)); + + hasNoEntry(result, 'display'); + hasNoEntry(result, 'font-size'); + }); + }); + + group('CSS declaration values', () { + setUp(() { + ls.cache.clear(); + }); + + // TODO: The parser throws if the stylesheet is not valid which makes implementing completions a bit tricky. + // Error: Expected expression. + // ╷ + // 2 │ display: ; + // │ ^ + // ╵ + // - 2:12 root stylesheet + // package:sass/src/utils.dart 428:3 throwWithTrace + // package:sass/src/parse/parser.dart 732:7 Parser.wrapSpanFormatException + // package:sass/src/parse/stylesheet.dart 86:12 StylesheetParser.parse + // package:sass/src/ast/sass/statement/stylesheet.dart 134:38 new Stylesheet.parseScss + // package:sass_language_services/src/language_services_cache.dart 35:38 LanguageServicesCache.getStylesheet + // package:sass_language_services/src/language_services.dart 106:18 LanguageServices.parseStylesheet + // package:sass_language_services/src/features/completion/completion_feature.dart 52:25 CompletionFeature.doComplete + // package:sass_language_services/src/language_services.dart 61:24 LanguageServices.doComplete + // test/features/completion/completion_test.dart 94:29 main.. + test('for display', () async { + var document = fs.createDocument(r''' +.a { + display: ; +} +'''); + var result = await ls.doComplete(document, at(line: 1, char: 11)); + + expect(result.items, isNotEmpty); + + hasEntry(result, 'block'); + + // Should not suggest new declarations + // or irrelevant values. + hasNoEntry(result, 'display'); + hasNoEntry(result, 'bottom'); // vertical-align value + }); + }); +}