diff --git a/README.md b/README.md index 2c0909c75..cc919bdbd 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This repo contains the tools which provide editor integrations for Svelte files ## Packages -This repo uses [`pnpm workspaces`](https://pnpm.io/workspaces/), which TLDR means if you want to run a commands in each project then you can either `cd` to that directory and run the command, or use `pnpm -r [command]`. +This repo uses [`pnpm workspaces`](https://pnpm.io/workspaces/), which TLDR means if you want to run a command in each project then you can either `cd` to that directory and run the command, or use `pnpm -r [command]`. For example `pnpm -r test`. diff --git a/package.json b/package.json index 78483ce60..b74048576 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "prettier --check ." }, "dependencies": { - "typescript": "~5.6.3" + "typescript": "^5.7.2" }, "devDependencies": { "cross-env": "^7.0.2", diff --git a/packages/language-server/README.md b/packages/language-server/README.md index 9372d9687..0862842b9 100644 --- a/packages/language-server/README.md +++ b/packages/language-server/README.md @@ -289,6 +289,10 @@ Whether or not to show a code lens at the top of Svelte files indicating if they The default language to use when generating new script tags in Svelte. _Default_: `none` +#### `svelte.plugin.svelte.documentHighlight.enable` + +Enable document highlight support. Requires a restart. _Default_: `true` + ## Credits - [James Birtles](https://github.com/jamesbirtles) for creating the foundation which this language server is built on diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 3021cb374..616962025 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -62,7 +62,7 @@ "prettier-plugin-svelte": "^3.3.0", "svelte": "^4.2.19", "svelte2tsx": "workspace:~", - "typescript": "~5.6.3", + "typescript": "^5.7.2", "typescript-auto-import-cache": "^0.3.5", "vscode-css-languageservice": "~6.3.0", "vscode-html-languageservice": "~5.3.0", diff --git a/packages/language-server/src/lib/documentHighlight/wordHighlight.ts b/packages/language-server/src/lib/documentHighlight/wordHighlight.ts new file mode 100644 index 000000000..8e90a6f35 --- /dev/null +++ b/packages/language-server/src/lib/documentHighlight/wordHighlight.ts @@ -0,0 +1,89 @@ +import { + DocumentHighlight, + DocumentHighlightKind, + Position, + Range +} from 'vscode-languageserver-types'; +import { Document, TagInformation } from '../documents'; + +export function wordHighlightForTag( + document: Document, + position: Position, + tag: TagInformation | null, + wordPattern: RegExp +): DocumentHighlight[] | null { + if (!tag || tag.start === tag.end) { + return null; + } + + const offset = document.offsetAt(position); + + const text = document.getText(); + if ( + offset < tag.start || + offset > tag.end || + // empty before and after the cursor + !text.slice(offset - 1, offset + 1).trim() + ) { + return null; + } + + const word = wordAt(document, position, wordPattern); + if (!word) { + return null; + } + + const searching = document.getText().slice(tag.start, tag.end); + + const highlights: DocumentHighlight[] = []; + + let index = 0; + while (index < searching.length) { + index = searching.indexOf(word, index); + if (index === -1) { + break; + } + + const start = tag.start + index; + highlights.push({ + range: { + start: document.positionAt(start), + end: document.positionAt(start + word.length) + }, + kind: DocumentHighlightKind.Text + }); + + index += word.length; + } + + return highlights; +} + +function wordAt(document: Document, position: Position, wordPattern: RegExp): string | null { + const line = document + .getText( + Range.create(Position.create(position.line, 0), Position.create(position.line + 1, 0)) + ) + .trimEnd(); + + wordPattern.lastIndex = 0; + + let start: number | undefined; + let end: number | undefined; + const matchEnd = Math.min(position.character, line.length); + while (wordPattern.lastIndex < matchEnd) { + const match = wordPattern.exec(line); + if (!match) { + break; + } + + start = match.index; + end = match.index + match[0].length; + } + + if (start === undefined || end === undefined || end < position.character) { + return null; + } + + return line.slice(start, end); +} diff --git a/packages/language-server/src/lib/documents/Document.ts b/packages/language-server/src/lib/documents/Document.ts index 369808e2c..dde5a0e2f 100644 --- a/packages/language-server/src/lib/documents/Document.ts +++ b/packages/language-server/src/lib/documents/Document.ts @@ -5,6 +5,7 @@ import { parseHtml } from './parseHtml'; import { SvelteConfig, configLoader } from './configLoader'; import { HTMLDocument } from 'vscode-html-languageservice'; import { Range } from 'vscode-languageserver'; +import { importSvelte } from '../../importPackage'; /** * Represents a text document contains a svelte component. @@ -25,6 +26,16 @@ export class Document extends WritableDocument { */ private path = urlToPath(this.url); + private _compiler: typeof import('svelte/compiler') | undefined; + get compiler() { + return this.getCompiler(); + } + + private svelteVersion: [number, number] | undefined; + public get isSvelte5() { + return this.getSvelteVersion()[0] > 4; + } + constructor( public url: string, public content: string @@ -34,6 +45,13 @@ export class Document extends WritableDocument { this.updateDocInfo(); } + private getCompiler() { + if (!this._compiler) { + this._compiler = importSvelte(this.getFilePath() || ''); + } + return this._compiler; + } + private updateDocInfo() { this.html = parseHtml(this.content); const update = (config: SvelteConfig | undefined) => { @@ -66,6 +84,14 @@ export class Document extends WritableDocument { } } + getSvelteVersion() { + if (!this.svelteVersion) { + const [major, minor] = this.compiler.VERSION.split('.'); + this.svelteVersion = [Number(major), Number(minor)]; + } + return this.svelteVersion; + } + /** * Get text content */ diff --git a/packages/language-server/src/lib/documents/utils.ts b/packages/language-server/src/lib/documents/utils.ts index ff8b4c016..66043af0e 100644 --- a/packages/language-server/src/lib/documents/utils.ts +++ b/packages/language-server/src/lib/documents/utils.ts @@ -453,3 +453,11 @@ export function isInsideMoustacheTag(html: string, tagStart: number | null, posi return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}'); } } + +export function inStyleOrScript(document: Document, position: Position) { + return ( + isInTag(position, document.styleInfo) || + isInTag(position, document.scriptInfo) || + isInTag(position, document.moduleScriptInfo) + ); +} diff --git a/packages/language-server/src/plugins/PluginHost.ts b/packages/language-server/src/plugins/PluginHost.ts index d1613134f..8a4c3974d 100644 --- a/packages/language-server/src/plugins/PluginHost.ts +++ b/packages/language-server/src/plugins/PluginHost.ts @@ -16,6 +16,7 @@ import { CompletionList, DefinitionLink, Diagnostic, + DocumentHighlight, FoldingRange, FormattingOptions, Hover, @@ -677,6 +678,25 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { ); } + findDocumentHighlight( + textDocument: TextDocumentIdentifier, + position: Position + ): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return ( + this.execute( + 'findDocumentHighlight', + [document, position], + ExecuteMode.FirstNonNull, + 'high' + ) ?? [] // fall back to empty array to prevent fallback to word-based highlighting + ); + } + onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void { for (const support of this.plugins) { support.onWatchFileChanges?.(onWatchFileChangesParas); diff --git a/packages/language-server/src/plugins/css/CSSPlugin.ts b/packages/language-server/src/plugins/css/CSSPlugin.ts index d5a38d427..f89a6285e 100644 --- a/packages/language-server/src/plugins/css/CSSPlugin.ts +++ b/packages/language-server/src/plugins/css/CSSPlugin.ts @@ -14,6 +14,7 @@ import { CompletionItem, CompletionItemKind, SelectionRange, + DocumentHighlight, WorkspaceFolder } from 'vscode-languageserver'; import { @@ -36,6 +37,7 @@ import { CompletionsProvider, DiagnosticsProvider, DocumentColorsProvider, + DocumentHighlightProvider, DocumentSymbolsProvider, FoldingRangeProvider, HoverProvider, @@ -50,8 +52,12 @@ import { StyleAttributeDocument } from './StyleAttributeDocument'; import { getDocumentContext } from '../documentContext'; import { FoldingRange, FoldingRangeKind } from 'vscode-languageserver-types'; import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding'; +import { wordHighlightForTag } from '../../lib/documentHighlight/wordHighlight'; import { isNotNullOrUndefined, urlToPath } from '../../utils'; +// https://github.com/microsoft/vscode/blob/c6f507deeb99925e713271b1048f21dbaab4bd54/extensions/css/language-configuration.json#L34 +const wordPattern = /(#?-?\d*\.\d\w*%?)|(::?[\w-]*(?=[^,{;]*[,{]))|(([@#.!])?[\w-?]+%?|[@#!.])/g; + export class CSSPlugin implements HoverProvider, @@ -61,6 +67,7 @@ export class CSSPlugin ColorPresentationsProvider, DocumentSymbolsProvider, SelectionRangeProvider, + DocumentHighlightProvider, FoldingRangeProvider { __name = 'css'; @@ -388,7 +395,6 @@ export class CSSPlugin } const cssDocument = this.getCSSDoc(document); - if (shouldUseIndentBasedFolding(cssDocument.languageId)) { return this.nonSyntacticFolding(document, document.styleInfo); } @@ -441,6 +447,48 @@ export class CSSPlugin return ranges.sort((a, b) => a.startLine - b.startLine); } + findDocumentHighlight(document: Document, position: Position): DocumentHighlight[] | null { + const cssDocument = this.getCSSDoc(document); + if (cssDocument.isInGenerated(position)) { + if (shouldExcludeDocumentHighlights(cssDocument)) { + return wordHighlightForTag(document, position, document.styleInfo, wordPattern); + } + + return this.findDocumentHighlightInternal(cssDocument, position); + } + + const attributeContext = getAttributeContextAtPosition(document, position); + if ( + attributeContext && + this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText()) + ) { + const [start, end] = attributeContext.valueRange; + return this.findDocumentHighlightInternal( + new StyleAttributeDocument(document, start, end, this.cssLanguageServices), + position + ); + } + + return null; + } + + private findDocumentHighlightInternal( + cssDocument: CSSDocumentBase, + position: Position + ): DocumentHighlight[] | null { + const kind = extractLanguage(cssDocument); + + const result = getLanguageService(this.cssLanguageServices, kind) + .findDocumentHighlights( + cssDocument, + cssDocument.getGeneratedPosition(position), + cssDocument.stylesheet + ) + .map((highlight) => mapObjWithRangeToOriginal(cssDocument, highlight)); + + return result; + } + private getCSSDoc(document: Document) { let cssDoc = this.cssDocuments.get(document); if (!cssDoc || cssDoc.version < document.version) { @@ -535,6 +583,18 @@ function shouldUseIndentBasedFolding(kind?: string) { } } +function shouldExcludeDocumentHighlights(document: CSSDocumentBase) { + switch (extractLanguage(document)) { + case 'postcss': + case 'sass': + case 'stylus': + case 'styl': + return true; + default: + return false; + } +} + function isSASS(document: CSSDocumentBase) { switch (extractLanguage(document)) { case 'sass': diff --git a/packages/language-server/src/plugins/html/HTMLPlugin.ts b/packages/language-server/src/plugins/html/HTMLPlugin.ts index ffba79d62..75d1c3193 100644 --- a/packages/language-server/src/plugins/html/HTMLPlugin.ts +++ b/packages/language-server/src/plugins/html/HTMLPlugin.ts @@ -18,7 +18,8 @@ import { WorkspaceEdit, LinkedEditingRanges, CompletionContext, - FoldingRange + FoldingRange, + DocumentHighlight } from 'vscode-languageserver'; import { DocumentManager, @@ -33,7 +34,8 @@ import { CompletionsProvider, RenameProvider, LinkedEditingRangesProvider, - FoldingRangeProvider + FoldingRangeProvider, + DocumentHighlightProvider } from '../interfaces'; import { isInsideMoustacheTag, toRange } from '../../lib/documents/utils'; import { isNotNullOrUndefined, possiblyComponent } from '../../utils'; @@ -41,6 +43,10 @@ import { importPrettier } from '../../importPackage'; import path from 'path'; import { Logger } from '../../logger'; import { indentBasedFoldingRangeForTag } from '../../lib/foldingRange/indentFolding'; +import { wordHighlightForTag } from '../../lib/documentHighlight/wordHighlight'; + +// https://github.com/microsoft/vscode/blob/c6f507deeb99925e713271b1048f21dbaab4bd54/extensions/html/language-configuration.json#L34 +const wordPattern = /(-?\d*\.\d\w*)|([^`~!@$^&*()=+[{\]}\|;:'",.<>\/\s]+)/g; export class HTMLPlugin implements @@ -48,7 +54,8 @@ export class HTMLPlugin CompletionsProvider, RenameProvider, LinkedEditingRangesProvider, - FoldingRangeProvider + FoldingRangeProvider, + DocumentHighlightProvider { __name = 'html'; private lang = getLanguageService({ @@ -159,6 +166,7 @@ export class HTMLPlugin : null; const svelteStrictMode = prettierConfig?.svelteStrictMode; + items.forEach((item) => { const startQuote = svelteStrictMode ? '"{' : '{'; const endQuote = svelteStrictMode ? '}"' : '}'; @@ -171,6 +179,10 @@ export class HTMLPlugin ...item.textEdit, newText: item.textEdit.newText.replace('="$1"', `$2=${startQuote}$1${endQuote}`) }; + // In Svelte 5, people should use `onclick` instead of `on:click` + if (document.isSvelte5) { + item.sortText = 'z' + (item.sortText ?? item.label); + } } if (item.label.startsWith('bind:')) { @@ -182,11 +194,7 @@ export class HTMLPlugin }); return CompletionList.create( - [ - ...this.toCompletionItems(items), - ...this.getLangCompletions(items), - ...emmetResults.items - ], + [...items, ...this.getLangCompletions(items), ...emmetResults.items], // Emmet completions change on every keystroke, so they are never complete emmetResults.items.length > 0 ); @@ -408,6 +416,36 @@ export class HTMLPlugin return result.concat(templateRange); } + findDocumentHighlight(document: Document, position: Position): DocumentHighlight[] | null { + const html = this.documents.get(document); + if (!html) { + return null; + } + + const templateResult = wordHighlightForTag( + document, + position, + document.templateInfo, + wordPattern + ); + + if (templateResult) { + return templateResult; + } + + const node = html.findNodeAt(document.offsetAt(position)); + if (possiblyComponent(node)) { + return null; + } + const result = this.lang.findDocumentHighlights(document, position, html); + + if (!result.length) { + return null; + } + + return result; + } + /** * Returns true if rename happens at the tag name, not anywhere inbetween. */ diff --git a/packages/language-server/src/plugins/interfaces.ts b/packages/language-server/src/plugins/interfaces.ts index 7570c727c..73c7bbbbd 100644 --- a/packages/language-server/src/plugins/interfaces.ts +++ b/packages/language-server/src/plugins/interfaces.ts @@ -21,6 +21,7 @@ import { CompletionList, DefinitionLink, Diagnostic, + DocumentHighlight, FoldingRange, FormattingOptions, Hover, @@ -243,6 +244,13 @@ export interface FoldingRangeProvider { getFoldingRanges(document: Document): Resolvable; } +export interface DocumentHighlightProvider { + findDocumentHighlight( + document: Document, + position: Position + ): Resolvable; +} + export interface OnWatchFileChanges { onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void; } @@ -273,7 +281,8 @@ type ProviderBase = DiagnosticsProvider & InlayHintProvider & CallHierarchyProvider & FoldingRangeProvider & - CodeLensProvider; + CodeLensProvider & + DocumentHighlightProvider; export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider; diff --git a/packages/language-server/src/plugins/svelte/SvelteDocument.ts b/packages/language-server/src/plugins/svelte/SvelteDocument.ts index 65aa1146e..0619d1068 100644 --- a/packages/language-server/src/plugins/svelte/SvelteDocument.ts +++ b/packages/language-server/src/plugins/svelte/SvelteDocument.ts @@ -5,7 +5,6 @@ import { CompileOptions } from 'svelte/types/compiler/interfaces'; // @ts-ignore import { PreprocessorGroup, Processed } from 'svelte/types/compiler/preprocess'; import { Position } from 'vscode-languageserver'; -import { getPackageInfo, importSvelte } from '../../importPackage'; import { Document, DocumentMapper, @@ -37,7 +36,6 @@ type PositionMapper = Pick | undefined; private compileResult: Promise | undefined; - private svelteVersion: [number, number] | undefined; public script: TagInformation | null; public moduleScript: TagInformation | null; @@ -48,9 +46,6 @@ export class SvelteDocument { public get config() { return this.parent.configPromise; } - public get isSvelte5() { - return this.getSvelteVersion()[0] > 4; - } constructor(private parent: Document) { this.script = this.parent.scriptInfo; @@ -73,7 +68,7 @@ export class SvelteDocument { async getTranspiled(): Promise { if (!this.transpiledDoc) { - const [major, minor] = this.getSvelteVersion(); + const [major, minor] = this.parent.getSvelteVersion(); if (major > 3 || (major === 3 && minor >= 32)) { this.transpiledDoc = TranspiledSvelteDocument.create( @@ -99,16 +94,7 @@ export class SvelteDocument { } async getCompiledWith(options: CompileOptions = {}): Promise { - const svelte = importSvelte(this.getFilePath()); - return svelte.compile((await this.getTranspiled()).getText(), options); - } - - private getSvelteVersion() { - if (!this.svelteVersion) { - const { major, minor } = getPackageInfo('svelte', this.getFilePath()).version; - this.svelteVersion = [major, minor]; - } - return this.svelteVersion; + return this.parent.compiler.compile((await this.getTranspiled()).getText(), options); } } @@ -123,8 +109,7 @@ export class TranspiledSvelteDocument implements ITranspiledSvelteDocument { } const filename = document.getFilePath() || ''; - const svelte = importSvelte(filename); - const preprocessed = await svelte.preprocess( + const preprocessed = await document.compiler.preprocess( document.getText(), wrapPreprocessors(config?.preprocess), { @@ -453,8 +438,7 @@ async function transpile( return wrappedPreprocessor; }); - const svelte = importSvelte(document.getFilePath() || ''); - const result = await svelte.preprocess(document.getText(), wrappedPreprocessors, { + const result = await document.compiler.preprocess(document.getText(), wrappedPreprocessors, { filename: document.getFilePath() || '' }); const transpiled = result.code || result.toString?.() || ''; diff --git a/packages/language-server/src/plugins/svelte/SveltePlugin.ts b/packages/language-server/src/plugins/svelte/SveltePlugin.ts index 3fe7c49a5..70e00e9a0 100644 --- a/packages/language-server/src/plugins/svelte/SveltePlugin.ts +++ b/packages/language-server/src/plugins/svelte/SveltePlugin.ts @@ -16,7 +16,7 @@ import { WorkspaceEdit } from 'vscode-languageserver'; import { Plugin } from 'prettier'; -import { getPackageInfo, importPrettier, importSvelte } from '../../importPackage'; +import { getPackageInfo, importPrettier } from '../../importPackage'; import { Document } from '../../lib/documents'; import { Logger } from '../../logger'; import { LSConfigManager, LSSvelteConfig } from '../../ls-config'; @@ -52,9 +52,9 @@ export class SveltePlugin async getCodeLens(document: Document): Promise { if (!this.featureEnabled('runesLegacyModeCodeLens')) return null; + if (!document.isSvelte5) return null; const doc = await this.getSvelteDoc(document); - if (!doc.isSvelte5) return null; try { const result = await doc.getCompiled(); @@ -355,7 +355,7 @@ export class SveltePlugin private migrate(document: Document): WorkspaceEdit | string { try { - const compiler = importSvelte(document.getFilePath() ?? '') as any; + const compiler = document.compiler as any; if (!compiler.migrate) { return 'Your installed Svelte version does not support migration'; } diff --git a/packages/language-server/src/plugins/svelte/features/getCompletions.ts b/packages/language-server/src/plugins/svelte/features/getCompletions.ts index eefe5c3f7..8a4f0d5c0 100644 --- a/packages/language-server/src/plugins/svelte/features/getCompletions.ts +++ b/packages/language-server/src/plugins/svelte/features/getCompletions.ts @@ -9,10 +9,10 @@ import { MarkupKind } from 'vscode-languageserver'; import { SvelteTag, documentation, getLatestOpeningTag } from './SvelteTags'; -import { isInTag, Document } from '../../../lib/documents'; +import { Document } from '../../../lib/documents'; import { AttributeContext, getAttributeContextAtPosition } from '../../../lib/documents/parseHtml'; import { getModifierData } from './getModifierData'; -import { attributeCanHaveEventModifier } from './utils'; +import { attributeCanHaveEventModifier, inStyleOrScript } from './utils'; const HTML_COMMENT_START = ' @@ -9,7 +13,7 @@
{valueStr.substring(0)}{valueStr2.substring(0)}
- + {:else if typeof value === 'number'} {value.toFixed()} {/if} @@ -24,4 +28,4 @@ {value.substring(0)} {:else} {value.toFixed()} -{/if} \ No newline at end of file +{/if} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/parser-error/expected_svelte_5.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/parser-error/expected_svelte_5.json index c0fcc5b6d..65bcc0afb 100644 --- a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/parser-error/expected_svelte_5.json +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/parser-error/expected_svelte_5.json @@ -3,7 +3,7 @@ "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 0 } }, "severity": 1, "source": "js", - "message": "A component can have a single top-level `