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