From 22d750154a93f0b84d262168c18846a9c2829afd Mon Sep 17 00:00:00 2001 From: William Killerud Date: Sat, 21 Dec 2024 16:42:54 +0100 Subject: [PATCH] Completions --- .../lib/src/css/css_property.dart | 84 ++++++++++++-- .../completion/completion_feature.dart | 88 +++++++++++++-- .../lib/src/features/hover/hover_feature.dart | 77 ++++--------- .../lib/src/features/language_feature.dart | 11 ++ .../lib/src/utils/sass_lsp_utils.dart | 18 +++ .../features/completion/completion_test.dart | 105 ++++++++++++++++++ 6 files changed, 311 insertions(+), 72 deletions(-) create mode 100644 pkgs/sass_language_services/test/features/completion/completion_test.dart 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_feature.dart b/pkgs/sass_language_services/lib/src/features/completion/completion_feature.dart index 94f5fa8..92ff4d2 100644 --- a/pkgs/sass_language_services/lib/src/features/completion/completion_feature.dart +++ b/pkgs/sass_language_services/lib/src/features/completion/completion_feature.dart @@ -1,12 +1,28 @@ +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}); @@ -47,7 +63,7 @@ class CompletionFeature extends LanguageFeature { lineBeforePosition: lineBeforePosition, ); - for (var i = path.length; i >= 0; i--) { + for (var i = path.length - 1; i >= 0; i--) { var node = path[i]; if (node is sass.Declaration) { _declarationCompletion(node, context, result); @@ -130,12 +146,9 @@ class CompletionFeature extends LanguageFeature { // From offset, go back until hitting a newline var i = offset - 1; - var codeUnit = text.codeUnitAt(i); - const lineFeed = 10; // \n - const carriageReturn = 13; // \r - while (codeUnit != lineFeed && codeUnit != carriageReturn) { + var linebreaks = '\n\r'.codeUnits; + while (i >= 0 && !linebreaks.contains(text.codeUnitAt(i))) { i--; - codeUnit = text.codeUnitAt(i); } var lineBeforePosition = text.substring(i + 1, offset); @@ -150,8 +163,63 @@ class CompletionFeature extends LanguageFeature { return (lineBeforePosition, currentWord); } - void _declarationCompletion(sass.Declaration node, CompletionContext context, - CompletionList result) {} + 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) {} @@ -166,7 +234,9 @@ class CompletionFeature extends LanguageFeature { CompletionContext context, CompletionList result) {} void _styleRuleCompletion( - sass.StyleRule node, CompletionContext context, CompletionList result) {} + sass.StyleRule node, CompletionContext context, CompletionList result) { + _declarationCompletion(node, context, result); + } void _variableDeclarationCompletion(sass.VariableDeclaration node, CompletionContext context, CompletionList result) {} 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/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..6489186 --- /dev/null +++ b/pkgs/sass_language_services/test/features/completion/completion_test.dart @@ -0,0 +1,105 @@ +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(); + }); + + test('in an empty style rule', () 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 + }); + }); +}