|
| 1 | +import 'package:lsp_server/lsp_server.dart' as lsp; |
| 2 | +import 'package:sass_api/sass_api.dart' as sass; |
| 3 | +import 'package:sass_language_services/sass_language_services.dart'; |
| 4 | +import 'package:sass_language_services/src/utils/string_utils.dart'; |
| 5 | + |
| 6 | +import '../../css/css_data.dart'; |
| 7 | +import '../language_feature.dart'; |
| 8 | +import '../node_at_offset_visitor.dart'; |
| 9 | + |
| 10 | +class HoverFeature extends LanguageFeature { |
| 11 | + final _cssData = CssData(); |
| 12 | + |
| 13 | + HoverFeature({required super.ls}); |
| 14 | + |
| 15 | + bool _supportsMarkdown() => |
| 16 | + ls.clientCapabilities.textDocument?.hover?.contentFormat |
| 17 | + ?.any((f) => f == lsp.MarkupKind.Markdown) == |
| 18 | + true || |
| 19 | + ls.clientCapabilities.general?.markdown != null; |
| 20 | + |
| 21 | + Future<lsp.Hover?> doHover( |
| 22 | + TextDocument document, lsp.Position position) async { |
| 23 | + var stylesheet = ls.parseStylesheet(document); |
| 24 | + var offset = document.offsetAt(position); |
| 25 | + var visitor = NodeAtOffsetVisitor(offset); |
| 26 | + var result = stylesheet.accept(visitor); |
| 27 | + |
| 28 | + // The visitor might have reached the end of the syntax tree, |
| 29 | + // in which case result is null. We still might have a candidate. |
| 30 | + var hoverNode = result ?? visitor.candidate; |
| 31 | + if (hoverNode == null) { |
| 32 | + return null; |
| 33 | + } |
| 34 | + |
| 35 | + lsp.Hover? hover; |
| 36 | + for (var i = 0; i < visitor.path.length; i++) { |
| 37 | + var node = visitor.path.elementAt(i); |
| 38 | + if (node is sass.SimpleSelector) { |
| 39 | + return _selectorHover(visitor.path, i); |
| 40 | + } else if (node is sass.Declaration) { |
| 41 | + return _declarationHover(node); |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + return hover; |
| 46 | + } |
| 47 | + |
| 48 | + lsp.Hover _selectorHover(List<sass.AstNode> path, int index) { |
| 49 | + var (selector, specificity) = _getSelectorHoverValue(path, index); |
| 50 | + |
| 51 | + if (_supportsMarkdown()) { |
| 52 | + var contents = _asMarkdown('''```scss |
| 53 | +$selector |
| 54 | +``` |
| 55 | +
|
| 56 | +[Specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity): ${readableSpecificity(specificity)} |
| 57 | +'''); |
| 58 | + return lsp.Hover(contents: contents); |
| 59 | + } else { |
| 60 | + var contents = _asPlaintext(''' |
| 61 | +$selector |
| 62 | +
|
| 63 | +Specificity: ${readableSpecificity(specificity)} |
| 64 | +'''); |
| 65 | + return lsp.Hover(contents: contents); |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + /// Go back up the path and calculate a full selector string and specificity. |
| 70 | + (String, int) _getSelectorHoverValue(List<sass.AstNode> path, int index) { |
| 71 | + var selector = ""; |
| 72 | + var specificity = 0; |
| 73 | + var pastImmediateStyleRule = false; |
| 74 | + var lastWasParentSelector = false; |
| 75 | + |
| 76 | + for (var i = index; i >= 0; i--) { |
| 77 | + var node = path.elementAt(i); |
| 78 | + if (node is sass.ComplexSelector) { |
| 79 | + var sel = node.span.text; |
| 80 | + if (sel.startsWith('&')) { |
| 81 | + lastWasParentSelector = true; |
| 82 | + selector = "${sel.substring(1)} $selector"; |
| 83 | + specificity += node.specificity; |
| 84 | + } else { |
| 85 | + if (lastWasParentSelector) { |
| 86 | + selector = "$sel$selector"; |
| 87 | + } else { |
| 88 | + selector = "$sel $selector"; |
| 89 | + } |
| 90 | + specificity += node.specificity; |
| 91 | + } |
| 92 | + } else if (node is sass.StyleRule) { |
| 93 | + // Don't add the direct parent StyleRule, |
| 94 | + // otherwise we'll end up with the same selector twice. |
| 95 | + if (!pastImmediateStyleRule) { |
| 96 | + pastImmediateStyleRule = true; |
| 97 | + continue; |
| 98 | + } |
| 99 | + |
| 100 | + try { |
| 101 | + if (node.selector.isPlain) { |
| 102 | + var selectorList = sass.SelectorList.parse(node.selector.asPlain!); |
| 103 | + |
| 104 | + // Just pick the first one in case of a list. |
| 105 | + var ruleSelector = selectorList.components.first; |
| 106 | + var selectorString = ruleSelector.toString(); |
| 107 | + if (selectorString.startsWith('&')) { |
| 108 | + lastWasParentSelector = true; |
| 109 | + selector = "${selectorString.substring(1)} $selector"; |
| 110 | + specificity += ruleSelector.specificity; |
| 111 | + continue; |
| 112 | + } else { |
| 113 | + if (lastWasParentSelector) { |
| 114 | + selector = "$selectorString$selector"; |
| 115 | + // subtract one class worth that would otherwise be duplicated |
| 116 | + specificity -= 1000; |
| 117 | + } else { |
| 118 | + selector = "$selectorString $selector"; |
| 119 | + } |
| 120 | + specificity += ruleSelector.specificity; |
| 121 | + } |
| 122 | + } |
| 123 | + } on sass.SassFormatException catch (_) { |
| 124 | + // Do nothing. |
| 125 | + } |
| 126 | + |
| 127 | + lastWasParentSelector = false; |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + return (selector.trim(), specificity); |
| 132 | + } |
| 133 | + |
| 134 | + lsp.Either2<lsp.MarkupContent, String> _asMarkdown(String content) { |
| 135 | + return lsp.Either2.t1( |
| 136 | + lsp.MarkupContent( |
| 137 | + kind: lsp.MarkupKind.Markdown, |
| 138 | + value: content, |
| 139 | + ), |
| 140 | + ); |
| 141 | + } |
| 142 | + |
| 143 | + lsp.Either2<lsp.MarkupContent, String> _asPlaintext(String content) { |
| 144 | + return lsp.Either2.t1( |
| 145 | + lsp.MarkupContent( |
| 146 | + kind: lsp.MarkupKind.PlainText, |
| 147 | + value: content, |
| 148 | + ), |
| 149 | + ); |
| 150 | + } |
| 151 | + |
| 152 | + Future<lsp.Hover?> _declarationHover(sass.Declaration node) async { |
| 153 | + var data = _cssData.getProperty(node.name.toString()); |
| 154 | + if (data == null) return null; |
| 155 | + |
| 156 | + var description = data.description; |
| 157 | + var syntax = data.syntax; |
| 158 | + |
| 159 | + final re = RegExp(r'([A-Z]+)(\d+)?'); |
| 160 | + const browserNames = { |
| 161 | + "E": "Edge", |
| 162 | + "FF": "Firefox", |
| 163 | + "S": "Safari", |
| 164 | + "C": "Chrome", |
| 165 | + "IE": "IE", |
| 166 | + "O": "Opera", |
| 167 | + }; |
| 168 | + |
| 169 | + if (_supportsMarkdown()) { |
| 170 | + var browsers = data.browsers?.map<String>((b) { |
| 171 | + var matches = re.firstMatch(b); |
| 172 | + if (matches != null) { |
| 173 | + var browser = matches.group(1); |
| 174 | + var version = matches.group(2); |
| 175 | + return "| ${browserNames[browser]} | $version |"; |
| 176 | + } |
| 177 | + return b; |
| 178 | + }).join('\n'); |
| 179 | + |
| 180 | + var references = data.references |
| 181 | + ?.map<String>((r) => '[${r.name}](${r.uri.toString()})') |
| 182 | + .join('\n'); |
| 183 | + var contents = _asMarkdown(''' |
| 184 | +$description |
| 185 | +
|
| 186 | +Syntax: $syntax |
| 187 | +
|
| 188 | +$references |
| 189 | +
|
| 190 | +| Browser | Since version | |
| 191 | +| -- | -- | |
| 192 | +$browsers |
| 193 | +''' |
| 194 | + .trim()); |
| 195 | + return lsp.Hover(contents: contents); |
| 196 | + } else { |
| 197 | + var browsers = data.browsers?.map<String>((b) { |
| 198 | + var matches = re.firstMatch(b); |
| 199 | + if (matches != null) { |
| 200 | + var browser = matches.group(1); |
| 201 | + var version = matches.group(2); |
| 202 | + return "${browserNames[browser]} $version"; |
| 203 | + } |
| 204 | + return b; |
| 205 | + }).join(', '); |
| 206 | + |
| 207 | + var contents = _asPlaintext(''' |
| 208 | +$description |
| 209 | +
|
| 210 | +Syntax: $syntax |
| 211 | +
|
| 212 | +$browsers |
| 213 | +'''); |
| 214 | + return lsp.Hover(contents: contents); |
| 215 | + } |
| 216 | + } |
| 217 | +} |
0 commit comments