Skip to content

Commit a41f3af

Browse files
committed
Hover
1 parent 733858f commit a41f3af

File tree

9 files changed

+603
-4
lines changed

9 files changed

+603
-4
lines changed

pkgs/sass_language_server/lib/src/language_server.dart

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,17 @@ class LanguageServer {
152152
_log.debug('workspace root $_workspaceRoot');
153153

154154
_ls = LanguageServices(
155-
clientCapabilities: _clientCapabilities, fs: fileSystemProvider);
155+
clientCapabilities: _clientCapabilities,
156+
fs: fileSystemProvider,
157+
);
156158

157159
var serverCapabilities = ServerCapabilities(
158160
definitionProvider: Either2.t1(true),
159161
documentHighlightProvider: Either2.t1(true),
160162
documentLinkProvider: DocumentLinkOptions(resolveProvider: false),
161163
documentSymbolProvider: Either2.t1(true),
162164
foldingRangeProvider: Either3.t1(true),
165+
hoverProvider: Either2.t1(true),
163166
referencesProvider: Either2.t1(true),
164167
renameProvider: Either2.t2(RenameOptions(prepareProvider: true)),
165168
selectionRangeProvider: Either3.t1(true),
@@ -324,6 +327,27 @@ class LanguageServer {
324327
_connection.peer
325328
.registerMethod('textDocument/documentSymbol', onDocumentSymbol);
326329

330+
_connection.onHover((params) async {
331+
try {
332+
var document = _documents.get(params.textDocument.uri);
333+
if (document == null) {
334+
// TODO: Would like to return null instead of empty content.
335+
return Hover(contents: Either2.t2(""));
336+
}
337+
338+
var configuration = _getLanguageConfiguration(document);
339+
if (configuration.hover.enabled) {
340+
var result = await _ls.hover(document, params.position);
341+
return result ?? Hover(contents: Either2.t2(""));
342+
} else {
343+
return Hover(contents: Either2.t2(""));
344+
}
345+
} on Exception catch (e) {
346+
_log.debug(e.toString());
347+
return Hover(contents: Either2.t2(""));
348+
}
349+
});
350+
327351
_connection.onReferences((params) async {
328352
try {
329353
var document = _documents.get(params.textDocument.uri);

pkgs/sass_language_services/lib/src/configuration/language_configuration.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
class FeatureConfiguration {
2-
late final bool enabled;
2+
final bool enabled;
33

44
FeatureConfiguration({required this.enabled});
55
}
66

7+
class HoverConfiguration extends FeatureConfiguration {
8+
final bool documentation;
9+
final bool references;
10+
11+
HoverConfiguration({
12+
required super.enabled,
13+
required this.documentation,
14+
required this.references,
15+
});
16+
}
17+
718
class LanguageConfiguration {
819
late final FeatureConfiguration definition;
920
late final FeatureConfiguration documentSymbols;
1021
late final FeatureConfiguration documentLinks;
1122
late final FeatureConfiguration foldingRanges;
1223
late final FeatureConfiguration highlights;
24+
late final HoverConfiguration hover;
1325
late final FeatureConfiguration references;
1426
late final FeatureConfiguration rename;
1527
late final FeatureConfiguration selectionRanges;
@@ -26,6 +38,13 @@ class LanguageConfiguration {
2638
enabled: config?['foldingRanges']?['enabled'] as bool? ?? true);
2739
highlights = FeatureConfiguration(
2840
enabled: config?['highlights']?['enabled'] as bool? ?? true);
41+
42+
hover = HoverConfiguration(
43+
enabled: config?['hover']?['enabled'] as bool? ?? true,
44+
documentation: config?['hover']?['documentation'] as bool? ?? true,
45+
references: config?['hover']?['references'] as bool? ?? true,
46+
);
47+
2948
references = FeatureConfiguration(
3049
enabled: config?['references']?['enabled'] as bool? ?? true);
3150
rename = FeatureConfiguration(
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
}

pkgs/sass_language_services/lib/src/features/node_at_offset_visitor.dart

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:sass_api/sass_api.dart' as sass;
2+
import 'package:sass_language_services/src/features/selector_at_offset_visitor.dart';
23

34
sass.AstNode? getNodeAtOffset(sass.ParentStatement node, int offset) {
45
if (node.span.start.offset > offset || offset > node.span.end.offset) {
@@ -16,6 +17,7 @@ class NodeAtOffsetVisitor
1617
sass.StatementSearchVisitor<sass.AstNode>,
1718
sass.AstSearchVisitor<sass.AstNode> {
1819
sass.AstNode? candidate;
20+
final List<sass.AstNode> path = [];
1921
final int _offset;
2022

2123
/// Finds the node with the shortest span at [offset].
@@ -33,6 +35,7 @@ class NodeAtOffsetVisitor
3335
if (containsOffset) {
3436
if (candidate == null) {
3537
candidate = node;
38+
path.add(node);
3639
processCandidate(node);
3740
} else {
3841
var nodeLength = nodeEndOffset - nodeStartOffset;
@@ -42,13 +45,14 @@ class NodeAtOffsetVisitor
4245
candidateSpan.end.offset - candidateSpan.start.offset;
4346
if (nodeLength <= candidateLength) {
4447
candidate = node;
48+
path.add(node);
4549
processCandidate(node);
4650
}
4751
}
4852
}
4953

5054
if (nodeStartOffset > _offset) {
51-
return candidate;
55+
// return candidate;
5256
}
5357

5458
return null;
@@ -233,7 +237,27 @@ class NodeAtOffsetVisitor
233237

234238
@override
235239
sass.AstNode? visitStyleRule(sass.StyleRule node) {
236-
return _process(node) ?? super.visitStyleRule(node);
240+
var result = _process(node);
241+
if (result != null) return result;
242+
243+
try {
244+
if (node.selector.isPlain) {
245+
var span = node.span;
246+
var selectorList = sass.SelectorList.parse(node.selector.asPlain!);
247+
var visitor = SelectorAtOffsetVisitor(_offset - span.start.offset);
248+
var result = selectorList.accept(visitor) ?? visitor.candidate;
249+
250+
if (result != null) {
251+
candidate = result;
252+
path.addAll(visitor.path);
253+
return result;
254+
}
255+
}
256+
} on sass.SassFormatException catch (_) {
257+
// Do nothing.
258+
}
259+
260+
return super.visitStyleRule(node);
237261
}
238262

239263
@override

0 commit comments

Comments
 (0)