Skip to content

Commit

Permalink
Hover
Browse files Browse the repository at this point in the history
  • Loading branch information
wkillerud committed Dec 11, 2024
1 parent 733858f commit a41f3af
Show file tree
Hide file tree
Showing 9 changed files with 603 additions and 4 deletions.
26 changes: 25 additions & 1 deletion pkgs/sass_language_server/lib/src/language_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,17 @@ class LanguageServer {
_log.debug('workspace root $_workspaceRoot');

_ls = LanguageServices(
clientCapabilities: _clientCapabilities, fs: fileSystemProvider);
clientCapabilities: _clientCapabilities,
fs: fileSystemProvider,
);

var serverCapabilities = ServerCapabilities(
definitionProvider: Either2.t1(true),
documentHighlightProvider: Either2.t1(true),
documentLinkProvider: DocumentLinkOptions(resolveProvider: false),
documentSymbolProvider: Either2.t1(true),
foldingRangeProvider: Either3.t1(true),
hoverProvider: Either2.t1(true),
referencesProvider: Either2.t1(true),
renameProvider: Either2.t2(RenameOptions(prepareProvider: true)),
selectionRangeProvider: Either3.t1(true),
Expand Down Expand Up @@ -324,6 +327,27 @@ class LanguageServer {
_connection.peer
.registerMethod('textDocument/documentSymbol', onDocumentSymbol);

_connection.onHover((params) async {
try {
var document = _documents.get(params.textDocument.uri);
if (document == null) {
// TODO: Would like to return null instead of empty content.
return Hover(contents: Either2.t2(""));
}

var configuration = _getLanguageConfiguration(document);
if (configuration.hover.enabled) {
var result = await _ls.hover(document, params.position);
return result ?? Hover(contents: Either2.t2(""));
} else {
return Hover(contents: Either2.t2(""));
}
} on Exception catch (e) {
_log.debug(e.toString());
return Hover(contents: Either2.t2(""));
}
});

_connection.onReferences((params) async {
try {
var document = _documents.get(params.textDocument.uri);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
class FeatureConfiguration {
late final bool enabled;
final bool enabled;

FeatureConfiguration({required this.enabled});
}

class HoverConfiguration extends FeatureConfiguration {
final bool documentation;
final bool references;

HoverConfiguration({
required super.enabled,
required this.documentation,
required this.references,
});
}

class LanguageConfiguration {
late final FeatureConfiguration definition;
late final FeatureConfiguration documentSymbols;
late final FeatureConfiguration documentLinks;
late final FeatureConfiguration foldingRanges;
late final FeatureConfiguration highlights;
late final HoverConfiguration hover;
late final FeatureConfiguration references;
late final FeatureConfiguration rename;
late final FeatureConfiguration selectionRanges;
Expand All @@ -26,6 +38,13 @@ class LanguageConfiguration {
enabled: config?['foldingRanges']?['enabled'] as bool? ?? true);
highlights = FeatureConfiguration(
enabled: config?['highlights']?['enabled'] as bool? ?? true);

hover = HoverConfiguration(
enabled: config?['hover']?['enabled'] as bool? ?? true,
documentation: config?['hover']?['documentation'] as bool? ?? true,
references: config?['hover']?['references'] as bool? ?? true,
);

references = FeatureConfiguration(
enabled: config?['references']?['enabled'] as bool? ?? true);
rename = FeatureConfiguration(
Expand Down
217 changes: 217 additions & 0 deletions pkgs/sass_language_services/lib/src/features/hover/hover_feature.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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/utils/string_utils.dart';

import '../../css/css_data.dart';
import '../language_feature.dart';
import '../node_at_offset_visitor.dart';

class HoverFeature extends LanguageFeature {
final _cssData = CssData();

HoverFeature({required super.ls});

bool _supportsMarkdown() =>
ls.clientCapabilities.textDocument?.hover?.contentFormat
?.any((f) => f == lsp.MarkupKind.Markdown) ==
true ||
ls.clientCapabilities.general?.markdown != null;

Future<lsp.Hover?> doHover(
TextDocument document, lsp.Position position) async {
var stylesheet = ls.parseStylesheet(document);
var offset = document.offsetAt(position);
var visitor = NodeAtOffsetVisitor(offset);
var result = stylesheet.accept(visitor);

// The visitor might have reached the end of the syntax tree,
// in which case result is null. We still might have a candidate.
var hoverNode = result ?? visitor.candidate;
if (hoverNode == null) {
return null;
}

lsp.Hover? hover;
for (var i = 0; i < visitor.path.length; i++) {
var node = visitor.path.elementAt(i);
if (node is sass.SimpleSelector) {
return _selectorHover(visitor.path, i);
} else if (node is sass.Declaration) {
return _declarationHover(node);
}
}

return hover;
}

lsp.Hover _selectorHover(List<sass.AstNode> path, int index) {
var (selector, specificity) = _getSelectorHoverValue(path, index);

if (_supportsMarkdown()) {
var contents = _asMarkdown('''```scss
$selector
```
[Specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity): ${readableSpecificity(specificity)}
''');
return lsp.Hover(contents: contents);
} else {
var contents = _asPlaintext('''
$selector
Specificity: ${readableSpecificity(specificity)}
''');
return lsp.Hover(contents: contents);
}
}

/// Go back up the path and calculate a full selector string and specificity.
(String, int) _getSelectorHoverValue(List<sass.AstNode> path, int index) {
var selector = "";
var specificity = 0;
var pastImmediateStyleRule = false;
var lastWasParentSelector = false;

for (var i = index; i >= 0; i--) {
var node = path.elementAt(i);
if (node is sass.ComplexSelector) {
var sel = node.span.text;
if (sel.startsWith('&')) {
lastWasParentSelector = true;
selector = "${sel.substring(1)} $selector";
specificity += node.specificity;
} else {
if (lastWasParentSelector) {
selector = "$sel$selector";
} else {
selector = "$sel $selector";
}
specificity += node.specificity;
}
} else if (node is sass.StyleRule) {
// Don't add the direct parent StyleRule,
// otherwise we'll end up with the same selector twice.
if (!pastImmediateStyleRule) {
pastImmediateStyleRule = true;
continue;
}

try {
if (node.selector.isPlain) {
var selectorList = sass.SelectorList.parse(node.selector.asPlain!);

// Just pick the first one in case of a list.
var ruleSelector = selectorList.components.first;
var selectorString = ruleSelector.toString();
if (selectorString.startsWith('&')) {
lastWasParentSelector = true;
selector = "${selectorString.substring(1)} $selector";
specificity += ruleSelector.specificity;
continue;
} else {
if (lastWasParentSelector) {
selector = "$selectorString$selector";
// subtract one class worth that would otherwise be duplicated
specificity -= 1000;
} else {
selector = "$selectorString $selector";
}
specificity += ruleSelector.specificity;
}
}
} on sass.SassFormatException catch (_) {
// Do nothing.
}

lastWasParentSelector = false;
}
}

return (selector.trim(), specificity);
}

lsp.Either2<lsp.MarkupContent, String> _asMarkdown(String content) {
return lsp.Either2.t1(
lsp.MarkupContent(
kind: lsp.MarkupKind.Markdown,
value: content,
),
);
}

lsp.Either2<lsp.MarkupContent, String> _asPlaintext(String content) {
return lsp.Either2.t1(
lsp.MarkupContent(
kind: lsp.MarkupKind.PlainText,
value: content,
),
);
}

Future<lsp.Hover?> _declarationHover(sass.Declaration node) async {
var data = _cssData.getProperty(node.name.toString());
if (data == null) return null;

var description = data.description;
var syntax = data.syntax;

final re = RegExp(r'([A-Z]+)(\d+)?');
const browserNames = {
"E": "Edge",
"FF": "Firefox",
"S": "Safari",
"C": "Chrome",
"IE": "IE",
"O": "Opera",
};

if (_supportsMarkdown()) {
var browsers = data.browsers?.map<String>((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('\n');

var references = data.references
?.map<String>((r) => '[${r.name}](${r.uri.toString()})')
.join('\n');
var contents = _asMarkdown('''
$description
Syntax: $syntax
$references
| Browser | Since version |
| -- | -- |
$browsers
'''
.trim());
return lsp.Hover(contents: contents);
} else {
var browsers = data.browsers?.map<String>((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
$browsers
''');
return lsp.Hover(contents: contents);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:sass_api/sass_api.dart' as sass;
import 'package:sass_language_services/src/features/selector_at_offset_visitor.dart';

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

/// Finds the node with the shortest span at [offset].
Expand All @@ -33,6 +35,7 @@ class NodeAtOffsetVisitor
if (containsOffset) {
if (candidate == null) {
candidate = node;
path.add(node);
processCandidate(node);
} else {
var nodeLength = nodeEndOffset - nodeStartOffset;
Expand All @@ -42,13 +45,14 @@ class NodeAtOffsetVisitor
candidateSpan.end.offset - candidateSpan.start.offset;
if (nodeLength <= candidateLength) {
candidate = node;
path.add(node);
processCandidate(node);
}
}
}

if (nodeStartOffset > _offset) {
return candidate;
// return candidate;
}

return null;
Expand Down Expand Up @@ -233,7 +237,27 @@ class NodeAtOffsetVisitor

@override
sass.AstNode? visitStyleRule(sass.StyleRule node) {
return _process(node) ?? super.visitStyleRule(node);
var result = _process(node);
if (result != null) return result;

try {
if (node.selector.isPlain) {
var span = node.span;
var selectorList = sass.SelectorList.parse(node.selector.asPlain!);
var visitor = SelectorAtOffsetVisitor(_offset - span.start.offset);
var result = selectorList.accept(visitor) ?? visitor.candidate;

if (result != null) {
candidate = result;
path.addAll(visitor.path);
return result;
}
}
} on sass.SassFormatException catch (_) {
// Do nothing.
}

return super.visitStyleRule(node);
}

@override
Expand Down
Loading

0 comments on commit a41f3af

Please sign in to comment.