Skip to content

Commit

Permalink
Hover
Browse files Browse the repository at this point in the history
  • Loading branch information
wkillerud committed Dec 15, 2024
1 parent 733858f commit 6a63872
Show file tree
Hide file tree
Showing 11 changed files with 786 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
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class GoToDefinitionFeature extends LanguageFeature {
}) async {
for (var kind in kinds) {
// `@forward` may add a prefix to [name],
// but we're comparing it to symbols without that prefix.
// but in [document] the symbols are without that prefix.
var unprefixedName = kind == ReferenceKind.function ||
kind == ReferenceKind.mixin ||
kind == ReferenceKind.variable
Expand Down
266 changes: 266 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,266 @@
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/sass_lsp_utils.dart';
import 'package:sass_language_services/src/utils/string_utils.dart';

import '../../css/css_data.dart';
import '../go_to_definition/go_to_definition_feature.dart';
import '../node_at_offset_visitor.dart';

class HoverFeature extends GoToDefinitionFeature {
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;
}

String? docComment;
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) {
hover = _declarationHover(node);
} else if (node is sass.VariableDeclaration) {
docComment = node.comment?.docComment;
} else if (node is sass.VariableExpression) {
hover = await _variableHover(node, document, position, docComment);
}
}

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 (lastWasParentSelector) {
lastWasParentSelector = false;

if (selectorString.startsWith('&')) {
lastWasParentSelector = true;
selector = "${selectorString.substring(1)}$selector";
} else {
selector = "$selectorString$selector";
}
// subtract one class worth that would otherwise be duplicated
specificity -= 1000;
} else {
if (selectorString.startsWith('&')) {
lastWasParentSelector = true;
selector = "${selectorString.substring(1)} $selector";
} else {
selector = "$selectorString $selector";
}
}
specificity += ruleSelector.specificity;
}
} on sass.SassFormatException catch (_) {
// Do nothing.
}
}
}

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,
),
);
}

lsp.Hover? _declarationHover(sass.Declaration node) {
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(', ');

var references = data.references
?.map<String>((r) => '[${r.name}](${r.uri.toString()})')
.join('\n');
var contents = _asMarkdown('''
$description
Syntax: $syntax
$references
$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);
}
}

Future<lsp.Hover?> _variableHover(sass.VariableExpression node,
TextDocument document, lsp.Position position, String? docComment) async {
var name = node.nameSpan.text;
var range = toRange(node.nameSpan);

var definition = await goToDefinition(document, range.start);
if (definition == null) {
return null;
}

var definitionDocument = ls.cache.getDocument(definition.uri);
if (definitionDocument == null) {
return null;
}

var rawValue = getVariableDeclarationValue(
definitionDocument,
definition.range.start,
);

var value = await findVariableValue(
definitionDocument,
definition.range.start,
);

if (_supportsMarkdown()) {
var contents = _asMarkdown('''
```${document.languageId}
${docComment != null ? '$docComment\n' : ''}$name: ${value ?? rawValue}${document.languageId != 'sass' ? ';' : ''}
```
''');
return lsp.Hover(contents: contents, range: range);
} else {
var contents = _asPlaintext('''
${docComment != null ? '$docComment\n' : ''}$name: ${value ?? rawValue}${document.languageId != 'sass' ? ';' : ''}
''');
return lsp.Hover(contents: contents, range: range);
}
}
}
Loading

0 comments on commit 6a63872

Please sign in to comment.