Skip to content

Commit 931dd85

Browse files
feat: Semantic document highlight (#1408)
* ts, html, css * WIP * fast forward * general svelte blocks and tag * only same file highlights * else highlights for if block * await block * highlight in style attribute * exclude unsupported * don't filter out highlights if original is not in it function and class highlght its name * config * ts, html and css test * svelte test * format * merge two utils files * revert unnecessary changes * single config, mark as experimental * move the svelte highlight to ts using ast returned by svelte2tsx * config description * word based highlight for unsupported languages * format * ignore for svelte 5 * fix svelte 5 issues. workaround the await block issue and fixing it separately * Apply suggestions from code review Co-authored-by: Simon H <[email protected]> * prevent word-base fallback * fix nested if, if block without else if * remove experimental keep the config so people can still go back to the word base highlight * fix svelte 4 again * fix * Update packages/language-server/src/plugins/PluginHost.ts * fix the fix --------- Co-authored-by: Simon H <[email protected]> Co-authored-by: Simon Holthausen <[email protected]>
1 parent 646f2e6 commit 931dd85

File tree

20 files changed

+1023
-33
lines changed

20 files changed

+1023
-33
lines changed

packages/language-server/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ Whether or not to show a code lens at the top of Svelte files indicating if they
289289

290290
The default language to use when generating new script tags in Svelte. _Default_: `none`
291291

292+
#### `svelte.plugin.svelte.documentHighlight.enable`
293+
294+
Enable document highlight support. Requires a restart. _Default_: `true`
295+
292296
## Credits
293297

294298
- [James Birtles](https://github.com/jamesbirtles) for creating the foundation which this language server is built on
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
DocumentHighlight,
3+
DocumentHighlightKind,
4+
Position,
5+
Range
6+
} from 'vscode-languageserver-types';
7+
import { Document, TagInformation } from '../documents';
8+
9+
export function wordHighlightForTag(
10+
document: Document,
11+
position: Position,
12+
tag: TagInformation | null,
13+
wordPattern: RegExp
14+
): DocumentHighlight[] | null {
15+
if (!tag || tag.start === tag.end) {
16+
return null;
17+
}
18+
19+
const offset = document.offsetAt(position);
20+
21+
const text = document.getText();
22+
if (
23+
offset < tag.start ||
24+
offset > tag.end ||
25+
// empty before and after the cursor
26+
!text.slice(offset - 1, offset + 1).trim()
27+
) {
28+
return null;
29+
}
30+
31+
const word = wordAt(document, position, wordPattern);
32+
if (!word) {
33+
return null;
34+
}
35+
36+
const searching = document.getText().slice(tag.start, tag.end);
37+
38+
const highlights: DocumentHighlight[] = [];
39+
40+
let index = 0;
41+
while (index < searching.length) {
42+
index = searching.indexOf(word, index);
43+
if (index === -1) {
44+
break;
45+
}
46+
47+
const start = tag.start + index;
48+
highlights.push({
49+
range: {
50+
start: document.positionAt(start),
51+
end: document.positionAt(start + word.length)
52+
},
53+
kind: DocumentHighlightKind.Text
54+
});
55+
56+
index += word.length;
57+
}
58+
59+
return highlights;
60+
}
61+
62+
function wordAt(document: Document, position: Position, wordPattern: RegExp): string | null {
63+
const line = document
64+
.getText(
65+
Range.create(Position.create(position.line, 0), Position.create(position.line + 1, 0))
66+
)
67+
.trimEnd();
68+
69+
wordPattern.lastIndex = 0;
70+
71+
let start: number | undefined;
72+
let end: number | undefined;
73+
const matchEnd = Math.min(position.character, line.length);
74+
while (wordPattern.lastIndex < matchEnd) {
75+
const match = wordPattern.exec(line);
76+
if (!match) {
77+
break;
78+
}
79+
80+
start = match.index;
81+
end = match.index + match[0].length;
82+
}
83+
84+
if (start === undefined || end === undefined || end < position.character) {
85+
return null;
86+
}
87+
88+
return line.slice(start, end);
89+
}

packages/language-server/src/lib/documents/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,11 @@ export function isInsideMoustacheTag(html: string, tagStart: number | null, posi
453453
return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}');
454454
}
455455
}
456+
457+
export function inStyleOrScript(document: Document, position: Position) {
458+
return (
459+
isInTag(position, document.styleInfo) ||
460+
isInTag(position, document.scriptInfo) ||
461+
isInTag(position, document.moduleScriptInfo)
462+
);
463+
}

packages/language-server/src/plugins/PluginHost.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
CompletionList,
1717
DefinitionLink,
1818
Diagnostic,
19+
DocumentHighlight,
1920
FoldingRange,
2021
FormattingOptions,
2122
Hover,
@@ -677,6 +678,25 @@ export class PluginHost implements LSProvider, OnWatchFileChanges {
677678
);
678679
}
679680

681+
findDocumentHighlight(
682+
textDocument: TextDocumentIdentifier,
683+
position: Position
684+
): Promise<DocumentHighlight[] | null> {
685+
const document = this.getDocument(textDocument.uri);
686+
if (!document) {
687+
throw new Error('Cannot call methods on an unopened document');
688+
}
689+
690+
return (
691+
this.execute<DocumentHighlight[] | null>(
692+
'findDocumentHighlight',
693+
[document, position],
694+
ExecuteMode.FirstNonNull,
695+
'high'
696+
) ?? [] // fall back to empty array to prevent fallback to word-based highlighting
697+
);
698+
}
699+
680700
onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void {
681701
for (const support of this.plugins) {
682702
support.onWatchFileChanges?.(onWatchFileChangesParas);

packages/language-server/src/plugins/css/CSSPlugin.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
CompletionItem,
1515
CompletionItemKind,
1616
SelectionRange,
17+
DocumentHighlight,
1718
WorkspaceFolder
1819
} from 'vscode-languageserver';
1920
import {
@@ -36,6 +37,7 @@ import {
3637
CompletionsProvider,
3738
DiagnosticsProvider,
3839
DocumentColorsProvider,
40+
DocumentHighlightProvider,
3941
DocumentSymbolsProvider,
4042
FoldingRangeProvider,
4143
HoverProvider,
@@ -50,8 +52,12 @@ import { StyleAttributeDocument } from './StyleAttributeDocument';
5052
import { getDocumentContext } from '../documentContext';
5153
import { FoldingRange, FoldingRangeKind } from 'vscode-languageserver-types';
5254
import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding';
55+
import { wordHighlightForTag } from '../../lib/documentHighlight/wordHighlight';
5356
import { isNotNullOrUndefined, urlToPath } from '../../utils';
5457

58+
// https://github.com/microsoft/vscode/blob/c6f507deeb99925e713271b1048f21dbaab4bd54/extensions/css/language-configuration.json#L34
59+
const wordPattern = /(#?-?\d*\.\d\w*%?)|(::?[\w-]*(?=[^,{;]*[,{]))|(([@#.!])?[\w-?]+%?|[@#!.])/g;
60+
5561
export class CSSPlugin
5662
implements
5763
HoverProvider,
@@ -61,6 +67,7 @@ export class CSSPlugin
6167
ColorPresentationsProvider,
6268
DocumentSymbolsProvider,
6369
SelectionRangeProvider,
70+
DocumentHighlightProvider,
6471
FoldingRangeProvider
6572
{
6673
__name = 'css';
@@ -388,7 +395,6 @@ export class CSSPlugin
388395
}
389396

390397
const cssDocument = this.getCSSDoc(document);
391-
392398
if (shouldUseIndentBasedFolding(cssDocument.languageId)) {
393399
return this.nonSyntacticFolding(document, document.styleInfo);
394400
}
@@ -441,6 +447,48 @@ export class CSSPlugin
441447
return ranges.sort((a, b) => a.startLine - b.startLine);
442448
}
443449

450+
findDocumentHighlight(document: Document, position: Position): DocumentHighlight[] | null {
451+
const cssDocument = this.getCSSDoc(document);
452+
if (cssDocument.isInGenerated(position)) {
453+
if (shouldExcludeDocumentHighlights(cssDocument)) {
454+
return wordHighlightForTag(document, position, document.styleInfo, wordPattern);
455+
}
456+
457+
return this.findDocumentHighlightInternal(cssDocument, position);
458+
}
459+
460+
const attributeContext = getAttributeContextAtPosition(document, position);
461+
if (
462+
attributeContext &&
463+
this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())
464+
) {
465+
const [start, end] = attributeContext.valueRange;
466+
return this.findDocumentHighlightInternal(
467+
new StyleAttributeDocument(document, start, end, this.cssLanguageServices),
468+
position
469+
);
470+
}
471+
472+
return null;
473+
}
474+
475+
private findDocumentHighlightInternal(
476+
cssDocument: CSSDocumentBase,
477+
position: Position
478+
): DocumentHighlight[] | null {
479+
const kind = extractLanguage(cssDocument);
480+
481+
const result = getLanguageService(this.cssLanguageServices, kind)
482+
.findDocumentHighlights(
483+
cssDocument,
484+
cssDocument.getGeneratedPosition(position),
485+
cssDocument.stylesheet
486+
)
487+
.map((highlight) => mapObjWithRangeToOriginal(cssDocument, highlight));
488+
489+
return result;
490+
}
491+
444492
private getCSSDoc(document: Document) {
445493
let cssDoc = this.cssDocuments.get(document);
446494
if (!cssDoc || cssDoc.version < document.version) {
@@ -535,6 +583,18 @@ function shouldUseIndentBasedFolding(kind?: string) {
535583
}
536584
}
537585

586+
function shouldExcludeDocumentHighlights(document: CSSDocumentBase) {
587+
switch (extractLanguage(document)) {
588+
case 'postcss':
589+
case 'sass':
590+
case 'stylus':
591+
case 'styl':
592+
return true;
593+
default:
594+
return false;
595+
}
596+
}
597+
538598
function isSASS(document: CSSDocumentBase) {
539599
switch (extractLanguage(document)) {
540600
case 'sass':

packages/language-server/src/plugins/html/HTMLPlugin.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
WorkspaceEdit,
1919
LinkedEditingRanges,
2020
CompletionContext,
21-
FoldingRange
21+
FoldingRange,
22+
DocumentHighlight
2223
} from 'vscode-languageserver';
2324
import {
2425
DocumentManager,
@@ -33,22 +34,28 @@ import {
3334
CompletionsProvider,
3435
RenameProvider,
3536
LinkedEditingRangesProvider,
36-
FoldingRangeProvider
37+
FoldingRangeProvider,
38+
DocumentHighlightProvider
3739
} from '../interfaces';
3840
import { isInsideMoustacheTag, toRange } from '../../lib/documents/utils';
3941
import { isNotNullOrUndefined, possiblyComponent } from '../../utils';
4042
import { importPrettier } from '../../importPackage';
4143
import path from 'path';
4244
import { Logger } from '../../logger';
4345
import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding';
46+
import { wordHighlightForTag } from '../../lib/documentHighlight/wordHighlight';
47+
48+
// https://github.com/microsoft/vscode/blob/c6f507deeb99925e713271b1048f21dbaab4bd54/extensions/html/language-configuration.json#L34
49+
const wordPattern = /(-?\d*\.\d\w*)|([^`~!@$^&*()=+[{\]}\|;:'",.<>\/\s]+)/g;
4450

4551
export class HTMLPlugin
4652
implements
4753
HoverProvider,
4854
CompletionsProvider,
4955
RenameProvider,
5056
LinkedEditingRangesProvider,
51-
FoldingRangeProvider
57+
FoldingRangeProvider,
58+
DocumentHighlightProvider
5259
{
5360
__name = 'html';
5461
private lang = getLanguageService({
@@ -409,6 +416,36 @@ export class HTMLPlugin
409416
return result.concat(templateRange);
410417
}
411418

419+
findDocumentHighlight(document: Document, position: Position): DocumentHighlight[] | null {
420+
const html = this.documents.get(document);
421+
if (!html) {
422+
return null;
423+
}
424+
425+
const templateResult = wordHighlightForTag(
426+
document,
427+
position,
428+
document.templateInfo,
429+
wordPattern
430+
);
431+
432+
if (templateResult) {
433+
return templateResult;
434+
}
435+
436+
const node = html.findNodeAt(document.offsetAt(position));
437+
if (possiblyComponent(node)) {
438+
return null;
439+
}
440+
const result = this.lang.findDocumentHighlights(document, position, html);
441+
442+
if (!result.length) {
443+
return null;
444+
}
445+
446+
return result;
447+
}
448+
412449
/**
413450
* Returns true if rename happens at the tag name, not anywhere inbetween.
414451
*/

packages/language-server/src/plugins/interfaces.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
CompletionList,
2222
DefinitionLink,
2323
Diagnostic,
24+
DocumentHighlight,
2425
FoldingRange,
2526
FormattingOptions,
2627
Hover,
@@ -243,6 +244,13 @@ export interface FoldingRangeProvider {
243244
getFoldingRanges(document: Document): Resolvable<FoldingRange[]>;
244245
}
245246

247+
export interface DocumentHighlightProvider {
248+
findDocumentHighlight(
249+
document: Document,
250+
position: Position
251+
): Resolvable<DocumentHighlight[] | null>;
252+
}
253+
246254
export interface OnWatchFileChanges {
247255
onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void;
248256
}
@@ -273,7 +281,8 @@ type ProviderBase = DiagnosticsProvider &
273281
InlayHintProvider &
274282
CallHierarchyProvider &
275283
FoldingRangeProvider &
276-
CodeLensProvider;
284+
CodeLensProvider &
285+
DocumentHighlightProvider;
277286

278287
export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider;
279288

0 commit comments

Comments
 (0)