From 6192d75f518a157889263dc59b0e63e1d02da815 Mon Sep 17 00:00:00 2001 From: "Lyu, Wei-Da" <36730922+jasonlyu123@users.noreply.github.com> Date: Tue, 30 Jul 2024 22:53:08 +0800 Subject: [PATCH] feat: reference/implementation codelens (#2378) #2130 Porting VSCode's implementation/reference code lens. This is not enabled by default. To enable this, you'll need to enable the typescript.implementationsCodeLens.enabled config or the typescript.referencesCodeLens.enabled config for lang="ts" and javascript.referencesCodeLens.enabled for js components. Note that we reuse config for ts/js files so it'll also enable the feature in ts/js files. --- packages/language-server/src/ls-config.ts | 22 ++ .../language-server/src/plugins/PluginHost.ts | 45 ++- .../language-server/src/plugins/interfaces.ts | 22 +- .../plugins/typescript/TypeScriptPlugin.ts | 33 +- .../typescript/features/CodeLensProvider.ts | 315 ++++++++++++++++++ .../features/FindReferencesProvider.ts | 45 ++- .../features/ImplementationProvider.ts | 17 +- packages/language-server/src/server.ts | 24 +- .../features/CodeLensProvider.test.ts | 209 ++++++++++++ .../testfiles/codelens/importing.svelte | 5 + .../testfiles/codelens/references.svelte | 7 + packages/svelte-vscode/src/extension.ts | 4 + packages/svelte-vscode/src/middlewares.ts | 43 +++ 13 files changed, 767 insertions(+), 24 deletions(-) create mode 100644 packages/language-server/src/plugins/typescript/features/CodeLensProvider.ts create mode 100644 packages/language-server/test/plugins/typescript/features/CodeLensProvider.test.ts create mode 100644 packages/language-server/test/plugins/typescript/testfiles/codelens/importing.svelte create mode 100644 packages/language-server/test/plugins/typescript/testfiles/codelens/references.svelte create mode 100644 packages/svelte-vscode/src/middlewares.ts diff --git a/packages/language-server/src/ls-config.ts b/packages/language-server/src/ls-config.ts index 42de751ea..39e098ab3 100644 --- a/packages/language-server/src/ls-config.ts +++ b/packages/language-server/src/ls-config.ts @@ -201,6 +201,8 @@ export interface TSUserConfig { suggest?: TSSuggestConfig; format?: TsFormatConfig; inlayHints?: TsInlayHintsConfig; + referencesCodeLens?: TsReferenceCodeLensConfig; + implementationsCodeLens?: TsImplementationCodeLensConfig; } /** @@ -252,6 +254,16 @@ export interface TsInlayHintsConfig { variableTypes: { enabled: boolean; suppressWhenTypeMatchesName: boolean } | undefined; } +export interface TsReferenceCodeLensConfig { + showOnAllFunctions?: boolean | undefined; + enabled: boolean; +} + +export interface TsImplementationCodeLensConfig { + enabled: boolean; + showOnInterfaceMethods?: boolean | undefined; +} + export type TsUserConfigLang = 'typescript' | 'javascript'; /** @@ -285,6 +297,11 @@ export class LSConfigManager { typescript: {}, javascript: {} }; + private rawTsUserConfig: Record = { + typescript: {}, + javascript: {} + }; + private resolvedAutoImportExcludeCache = new FileMap(); private tsFormatCodeOptions: Record = { typescript: this.getDefaultFormatCodeOptions(), @@ -396,6 +413,7 @@ export class LSConfigManager { (['typescript', 'javascript'] as const).forEach((lang) => { if (config[lang]) { this._updateTsUserPreferences(lang, config[lang]); + this.rawTsUserConfig[lang] = config[lang]; } }); this.notifyListeners(); @@ -498,6 +516,10 @@ export class LSConfigManager { }; } + getClientTsUserConfig(lang: TsUserConfigLang): TSUserConfig { + return this.rawTsUserConfig[lang]; + } + updateCssConfig(config: CssConfig | undefined): void { this.cssConfig = config; this.notifyListeners(); diff --git a/packages/language-server/src/plugins/PluginHost.ts b/packages/language-server/src/plugins/PluginHost.ts index b02009acf..a4783a6a1 100644 --- a/packages/language-server/src/plugins/PluginHost.ts +++ b/packages/language-server/src/plugins/PluginHost.ts @@ -7,6 +7,7 @@ import { CancellationToken, CodeAction, CodeActionContext, + CodeLens, Color, ColorInformation, ColorPresentation, @@ -417,13 +418,14 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { async findReferences( textDocument: TextDocumentIdentifier, position: Position, - context: ReferenceContext + context: ReferenceContext, + cancellationToken?: CancellationToken ): Promise { const document = this.getDocument(textDocument.uri); return await this.execute( 'findReferences', - [document, position, context], + [document, position, context, cancellationToken], ExecuteMode.FirstNonNull, 'high' ); @@ -525,13 +527,14 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { getImplementation( textDocument: TextDocumentIdentifier, - position: Position + position: Position, + cancellationToken?: CancellationToken ): Promise { const document = this.getDocument(textDocument.uri); return this.execute( 'getImplementation', - [document, position], + [document, position, cancellationToken], ExecuteMode.FirstNonNull, 'high' ); @@ -605,6 +608,20 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { ); } + async getCodeLens(textDocument: TextDocumentIdentifier) { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return await this.execute( + 'getCodeLens', + [document], + ExecuteMode.FirstNonNull, + 'smart' + ); + } + async getFoldingRanges(textDocument: TextDocumentIdentifier): Promise { const document = this.getDocument(textDocument.uri); @@ -620,6 +637,26 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { return result; } + async resolveCodeLens( + textDocument: TextDocumentIdentifier, + codeLens: CodeLens, + cancellationToken: CancellationToken + ) { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return ( + (await this.execute( + 'resolveCodeLens', + [document, codeLens, cancellationToken], + ExecuteMode.FirstNonNull, + 'smart' + )) ?? codeLens + ); + } + onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void { for (const support of this.plugins) { support.onWatchFileChanges?.(onWatchFileChangesParas); diff --git a/packages/language-server/src/plugins/interfaces.ts b/packages/language-server/src/plugins/interfaces.ts index cceef9558..7570c727c 100644 --- a/packages/language-server/src/plugins/interfaces.ts +++ b/packages/language-server/src/plugins/interfaces.ts @@ -13,6 +13,7 @@ import { CallHierarchyOutgoingCall, CodeAction, CodeActionContext, + CodeLens, Color, ColorInformation, ColorPresentation, @@ -150,7 +151,8 @@ export interface FindReferencesProvider { findReferences( document: Document, position: Position, - context: ReferenceContext + context: ReferenceContext, + cancellationToken?: CancellationToken ): Promise; } @@ -187,7 +189,11 @@ export interface LinkedEditingRangesProvider { } export interface ImplementationProvider { - getImplementation(document: Document, position: Position): Resolvable; + getImplementation( + document: Document, + position: Position, + cancellationToken?: CancellationToken + ): Resolvable; } export interface TypeDefinitionProvider { @@ -211,6 +217,15 @@ export interface CallHierarchyProvider { ): Resolvable; } +export interface CodeLensProvider { + getCodeLens(document: Document): Resolvable; + resolveCodeLens( + document: Document, + codeLensToResolve: CodeLens, + cancellationToken?: CancellationToken + ): Resolvable; +} + export interface OnWatchFileChangesPara { fileName: string; changeType: FileChangeType; @@ -257,7 +272,8 @@ type ProviderBase = DiagnosticsProvider & TypeDefinitionProvider & InlayHintProvider & CallHierarchyProvider & - FoldingRangeProvider; + FoldingRangeProvider & + CodeLensProvider; export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider; diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index 098bec745..bc97f6175 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -6,6 +6,7 @@ import { CancellationToken, CodeAction, CodeActionContext, + CodeLens, CompletionContext, CompletionList, DefinitionLink, @@ -41,6 +42,7 @@ import { AppCompletionList, CallHierarchyProvider, CodeActionsProvider, + CodeLensProvider, CompletionsProvider, DefinitionsProvider, DiagnosticsProvider, @@ -65,7 +67,6 @@ import { } from '../interfaces'; import { LSAndTSDocResolver } from './LSAndTSDocResolver'; import { ignoredBuildDirectories } from './SnapshotManager'; -import { CallHierarchyProviderImpl } from './features/CallHierarchyProvider'; import { CodeActionsProviderImpl } from './features/CodeActionsProvider'; import { CompletionResolveInfo, CompletionsProviderImpl } from './features/CompletionProvider'; import { DiagnosticsProviderImpl } from './features/DiagnosticsProvider'; @@ -97,6 +98,8 @@ import { isSvelteFilePath, symbolKindFromString } from './utils'; +import { CallHierarchyProviderImpl } from './features/CallHierarchyProvider'; +import { CodeLensProviderImpl } from './features/CodeLensProvider'; export class TypeScriptPlugin implements @@ -118,6 +121,7 @@ export class TypeScriptPlugin InlayHintProvider, CallHierarchyProvider, FoldingRangeProvider, + CodeLensProvider, OnWatchFileChanges, CompletionsProvider, UpdateTsOrJsFile @@ -144,6 +148,7 @@ export class TypeScriptPlugin private readonly inlayHintProvider: InlayHintProviderImpl; private readonly foldingRangeProvider: FoldingRangeProviderImpl; private readonly callHierarchyProvider: CallHierarchyProviderImpl; + private readonly codLensProvider: CodeLensProviderImpl; constructor( configManager: LSConfigManager, @@ -194,6 +199,12 @@ export class TypeScriptPlugin this.lsAndTsDocResolver, configManager ); + this.codLensProvider = new CodeLensProviderImpl( + this.lsAndTsDocResolver, + this.findReferencesProvider, + this.implementationProvider, + this.configManager + ); } async getDiagnostics( @@ -608,8 +619,12 @@ export class TypeScriptPlugin ); } - async getImplementation(document: Document, position: Position): Promise { - return this.implementationProvider.getImplementation(document, position); + async getImplementation( + document: Document, + position: Position, + cancellationToken?: CancellationToken + ): Promise { + return this.implementationProvider.getImplementation(document, position, cancellationToken); } async getTypeDefinition(document: Document, position: Position): Promise { @@ -658,6 +673,18 @@ export class TypeScriptPlugin return this.foldingRangeProvider.getFoldingRanges(document); } + getCodeLens(document: Document): Promise { + return this.codLensProvider.getCodeLens(document); + } + + resolveCodeLens( + document: Document, + codeLensToResolve: CodeLens, + cancellationToken?: CancellationToken + ): Promise { + return this.codLensProvider.resolveCodeLens(document, codeLensToResolve, cancellationToken); + } + private featureEnabled(feature: keyof LSTypescriptConfig) { return ( this.configManager.enabled('typescript.enable') && diff --git a/packages/language-server/src/plugins/typescript/features/CodeLensProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeLensProvider.ts new file mode 100644 index 000000000..99484b3d1 --- /dev/null +++ b/packages/language-server/src/plugins/typescript/features/CodeLensProvider.ts @@ -0,0 +1,315 @@ +import ts from 'typescript'; +import { CancellationToken, CodeLens, Range } from 'vscode-languageserver'; +import { Document, mapRangeToOriginal } from '../../../lib/documents'; +import { LSConfigManager, TSUserConfig } from '../../../ls-config'; +import { isZeroLengthRange } from '../../../utils'; +import { CodeLensProvider, FindReferencesProvider, ImplementationProvider } from '../../interfaces'; +import { SvelteDocumentSnapshot } from '../DocumentSnapshot'; +import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; +import { convertRange } from '../utils'; +import { isTextSpanInGeneratedCode } from './utils'; + +type CodeLensType = 'reference' | 'implementation'; + +interface CodeLensCollector { + type: CodeLensType; + collect: ( + tsDoc: SvelteDocumentSnapshot, + item: ts.NavigationTree, + parent: ts.NavigationTree | undefined + ) => Range | undefined; +} + +export class CodeLensProviderImpl implements CodeLensProvider { + constructor( + private readonly lsAndTsDocResolver: LSAndTSDocResolver, + private readonly referenceProvider: FindReferencesProvider, + private readonly implementationProvider: ImplementationProvider, + private readonly configManager: LSConfigManager + ) {} + + async getCodeLens(document: Document): Promise { + if (!this.anyCodeLensEnabled('typescript') && !this.anyCodeLensEnabled('javascript')) { + return null; + } + + const { lang, tsDoc } = await this.lsAndTsDocResolver.getLsForSyntheticOperations(document); + + const results: [CodeLensType, Range][] = []; + + const collectors: CodeLensCollector[] = []; + + const clientTsConfig = this.configManager.getClientTsUserConfig( + tsDoc.scriptKind === ts.ScriptKind.TS ? 'typescript' : 'javascript' + ); + + if (clientTsConfig.referencesCodeLens?.enabled) { + collectors.push({ + type: 'reference', + collect: (tsDoc, item, parent) => + this.extractReferenceLocation(tsDoc, item, parent, clientTsConfig) + }); + + if (!tsDoc.parserError) { + // always add a reference code lens for the generated component + results.push([ + 'reference', + { + start: { line: 0, character: 0 }, + // some client refused to resolve the code lens if the start is the same as the end + end: { line: 0, character: 1 } + } + ]); + } + } + + if ( + tsDoc.scriptKind === ts.ScriptKind.TS && + clientTsConfig.implementationsCodeLens?.enabled + ) { + collectors.push({ + type: 'implementation', + collect: (tsDoc, item, parent) => + this.extractImplementationLocation(tsDoc, item, clientTsConfig, parent) + }); + } + + if (!collectors.length) { + return null; + } + + const navigationTree = lang.getNavigationTree(tsDoc.filePath); + const renderFunction = navigationTree?.childItems?.find((item) => item.text === 'render'); + if (renderFunction) { + // pretty rare that there is anything to show in the template, so we skip it + const notTemplate = renderFunction.childItems?.filter( + (item) => item.text !== '' + ); + renderFunction.childItems = notTemplate; + } + + this.walkTree(tsDoc, navigationTree, undefined, results, collectors); + + const uri = document.uri; + return results.map(([type, range]) => CodeLens.create(range, { type, uri })); + } + + private anyCodeLensEnabled(lang: 'typescript' | 'javascript') { + const vscodeTsConfig = this.configManager.getClientTsUserConfig(lang); + return ( + vscodeTsConfig.referencesCodeLens?.enabled || + vscodeTsConfig.implementationsCodeLens?.enabled + ); + } + + /** + * https://github.com/microsoft/vscode/blob/062ba1ed6c2b9ff4819f4f7dad76de3fde0044ab/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts#L61 + */ + private extractReferenceLocation( + tsDoc: SvelteDocumentSnapshot, + item: ts.NavigationTree, + parent: ts.NavigationTree | undefined, + config: TSUserConfig + ): Range | undefined { + if (parent && parent.kind === ts.ScriptElementKind.enumElement) { + return this.getSymbolRange(tsDoc, item); + } + + switch (item.kind) { + case ts.ScriptElementKind.functionElement: { + const showOnAllFunctions = config.referencesCodeLens?.showOnAllFunctions; + + if (showOnAllFunctions) { + return this.getSymbolRange(tsDoc, item); + } + + if (this.isExported(item, tsDoc)) { + return this.getSymbolRange(tsDoc, item); + } + break; + } + + case ts.ScriptElementKind.constElement: + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + // Only show references for exported variables + if (this.isExported(item, tsDoc)) { + return this.getSymbolRange(tsDoc, item); + } + break; + + case ts.ScriptElementKind.classElement: + if (item.text === '') { + break; + } + return this.getSymbolRange(tsDoc, item); + + case ts.ScriptElementKind.interfaceElement: + case ts.ScriptElementKind.typeElement: + case ts.ScriptElementKind.enumElement: + return this.getSymbolRange(tsDoc, item); + + case ts.ScriptElementKind.memberFunctionElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + case ts.ScriptElementKind.constructorImplementationElement: + case ts.ScriptElementKind.memberVariableElement: + if (parent?.spans[0].start === item.spans[0].start) { + return undefined; + } + + // Only show if parent is a class type object (not a literal) + switch (parent?.kind) { + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.interfaceElement: + case ts.ScriptElementKind.typeElement: + return this.getSymbolRange(tsDoc, item); + } + break; + } + + return undefined; + } + + private isExported(item: ts.NavigationTree, tsDoc: SvelteDocumentSnapshot): boolean { + return !tsDoc.parserError && item.kindModifiers.match(/\bexport\b/g) !== null; + } + + /** + * https://github.com/microsoft/vscode/blob/062ba1ed6c2b9ff4819f4f7dad76de3fde0044ab/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts#L66 + */ + private extractImplementationLocation( + tsDoc: SvelteDocumentSnapshot, + item: ts.NavigationTree, + config: TSUserConfig, + parent?: ts.NavigationTree + ): Range | undefined { + if ( + item.kind === ts.ScriptElementKind.memberFunctionElement && + parent && + parent.kind === ts.ScriptElementKind.interfaceElement && + config.implementationsCodeLens?.showOnInterfaceMethods === true + ) { + return this.getSymbolRange(tsDoc, item); + } + switch (item.kind) { + case ts.ScriptElementKind.interfaceElement: + return this.getSymbolRange(tsDoc, item); + + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.memberFunctionElement: + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + if (item.kindModifiers.match(/\babstract\b/g)) { + return this.getSymbolRange(tsDoc, item); + } + break; + } + return undefined; + } + + private getSymbolRange( + tsDoc: SvelteDocumentSnapshot, + item: ts.NavigationTree + ): Range | undefined { + if (!item.nameSpan || isTextSpanInGeneratedCode(tsDoc.getFullText(), item.nameSpan)) { + return; + } + + const range = mapRangeToOriginal(tsDoc, convertRange(tsDoc, item.nameSpan)); + + if (range.start.line >= 0 && range.end.line >= 0) { + return isZeroLengthRange(range) ? undefined : range; + } + } + + private walkTree( + tsDoc: SvelteDocumentSnapshot, + item: ts.NavigationTree, + parent: ts.NavigationTree | undefined, + results: [CodeLensType, Range][], + collectors: CodeLensCollector[] + ) { + for (const collector of collectors) { + const range = collector.collect(tsDoc, item, parent); + if (range) { + results.push([collector.type, range]); + } + } + + item.childItems?.forEach((child) => this.walkTree(tsDoc, child, item, results, collectors)); + } + + async resolveCodeLens( + textDocument: Document, + codeLensToResolve: CodeLens, + cancellationToken?: CancellationToken + ): Promise { + if (codeLensToResolve.data.type === 'reference') { + return await this.resolveReferenceCodeLens( + textDocument, + codeLensToResolve, + cancellationToken + ); + } + + if (codeLensToResolve.data.type === 'implementation') { + return await this.resolveImplementationCodeLens( + textDocument, + codeLensToResolve, + cancellationToken + ); + } + + return codeLensToResolve; + } + + private async resolveReferenceCodeLens( + textDocument: Document, + codeLensToResolve: CodeLens, + cancellationToken?: CancellationToken + ) { + const references = + (await this.referenceProvider.findReferences( + textDocument, + codeLensToResolve.range.start, + { includeDeclaration: false }, + cancellationToken + )) ?? []; + + codeLensToResolve.command = { + title: references.length === 1 ? `1 reference` : `${references.length} references`, + // language clients need to map this to the corresponding command in each editor + // see example in svelte-vscode/src/middlewares.ts + command: '', + arguments: [textDocument.uri, codeLensToResolve.range.start, references] + }; + + return codeLensToResolve; + } + + private async resolveImplementationCodeLens( + textDocument: Document, + codeLensToResolve: CodeLens, + cancellationToken?: CancellationToken + ) { + const implementations = + (await this.implementationProvider.getImplementation( + textDocument, + codeLensToResolve.range.start, + cancellationToken + )) ?? []; + + codeLensToResolve.command = { + title: + implementations.length === 1 + ? `1 implementation` + : `${implementations.length} implementations`, + command: '', + arguments: [textDocument.uri, codeLensToResolve.range.start, implementations] + }; + + return codeLensToResolve; + } +} diff --git a/packages/language-server/src/plugins/typescript/features/FindReferencesProvider.ts b/packages/language-server/src/plugins/typescript/features/FindReferencesProvider.ts index 5f67fd61b..b609d5c5d 100644 --- a/packages/language-server/src/plugins/typescript/features/FindReferencesProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/FindReferencesProvider.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { Location, Position, ReferenceContext } from 'vscode-languageserver'; +import { CancellationToken, Location, Position, ReferenceContext } from 'vscode-languageserver'; import { Document } from '../../../lib/documents'; import { flatten, isNotNullOrUndefined, pathToUrl } from '../../../utils'; import { FindComponentReferencesProvider, FindReferencesProvider } from '../../interfaces'; @@ -28,13 +28,20 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { async findReferences( document: Document, position: Position, - context: ReferenceContext + context: ReferenceContext, + cancellationToken?: CancellationToken ): Promise { - if (this.isScriptStartOrEndTag(position, document)) { + if ( + this.isPositionForComponentCodeLens(position) || + this.isScriptStartOrEndTag(position, document) + ) { return this.componentReferencesProvider.findComponentReferences(document.uri); } const { lang, tsDoc } = await this.getLSAndTSDoc(document); + if (cancellationToken?.isCancellationRequested) { + return null; + } const offset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)); const rawReferences = lang.findReferences( @@ -61,10 +68,20 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { } const references = flatten(rawReferences.map((ref) => ref.references)); - references.push(...(await this.getStoreReferences(references, tsDoc, snapshots, lang))); + references.push( + ...(await this.getStoreReferences( + references, + tsDoc, + snapshots, + lang, + cancellationToken + )) + ); const locations = await Promise.all( - references.map(async (ref) => this.mapReference(ref, context, snapshots)) + references.map(async (ref) => + this.mapReference(ref, context, snapshots, cancellationToken) + ) ); return ( @@ -88,6 +105,10 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { ); } + private isPositionForComponentCodeLens(position: Position) { + return position.line === 0 && position.character === 0; + } + /** * If references of a $store are searched, also find references for the corresponding store * and vice versa. @@ -96,7 +117,8 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { references: ts.ReferencedSymbolEntry[], tsDoc: SvelteDocumentSnapshot, snapshots: SnapshotMap, - lang: ts.LanguageService + lang: ts.LanguageService, + cancellationToken: CancellationToken | undefined ): Promise { // If user started finding references at $store, find references for store, too let storeReferences: ts.ReferencedSymbolEntry[] = []; @@ -123,6 +145,10 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { const $storeReferences: ts.ReferencedSymbolEntry[] = []; for (const ref of [...references, ...storeReferences]) { const snapshot = await snapshots.retrieve(ref.fileName); + if (cancellationToken?.isCancellationRequested) { + return []; + } + if ( !( isTextSpanInGeneratedCode(snapshot.getFullText(), ref.textSpan) && @@ -193,7 +219,8 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { private async mapReference( ref: ts.ReferencedSymbolEntry, context: ReferenceContext, - snapshots: SnapshotMap + snapshots: SnapshotMap, + cancellationToken: CancellationToken | undefined ) { if (!context.includeDeclaration && ref.isDefinition) { return null; @@ -201,6 +228,10 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { const snapshot = await snapshots.retrieve(ref.fileName); + if (cancellationToken?.isCancellationRequested) { + return null; + } + if (isTextSpanInGeneratedCode(snapshot.getFullText(), ref.textSpan)) { return null; } diff --git a/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts b/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts index a74bb550d..e4a91c1bb 100644 --- a/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts @@ -1,4 +1,4 @@ -import { Position, Location } from 'vscode-languageserver-protocol'; +import { Position, Location, CancellationToken } from 'vscode-languageserver-protocol'; import { Document, mapLocationToOriginal } from '../../../lib/documents'; import { isNotNullOrUndefined } from '../../../utils'; import { ImplementationProvider } from '../../interfaces'; @@ -13,8 +13,17 @@ import { export class ImplementationProviderImpl implements ImplementationProvider { constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} - async getImplementation(document: Document, position: Position): Promise { + async getImplementation( + document: Document, + position: Position, + cancellationToken?: CancellationToken + ): Promise { const { tsDoc, lang } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + + if (cancellationToken?.isCancellationRequested) { + return null; + } + const offset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)); const implementations = lang.getImplementationAtPosition(tsDoc.filePath, offset); @@ -47,6 +56,10 @@ export class ImplementationProviderImpl implements ImplementationProvider { snapshot = await snapshots.retrieve(implementation.fileName); } + if (cancellationToken?.isCancellationRequested) { + return null; + } + const location = mapLocationToOriginal( snapshot, convertRange(snapshot, implementation.textSpan) diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 86a1c5836..3fffda855 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -310,7 +310,10 @@ export function startServer(options?: LSOptions) { typeDefinitionProvider: true, inlayHintProvider: true, callHierarchyProvider: true, - foldingRangeProvider: true + foldingRangeProvider: true, + codeLensProvider: { + resolveProvider: true + } } }; }); @@ -414,8 +417,8 @@ export function startServer(options?: LSOptions) { pluginHost.getDocumentSymbols(evt.textDocument, cancellationToken) ); connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position)); - connection.onReferences((evt) => - pluginHost.findReferences(evt.textDocument, evt.position, evt.context) + connection.onReferences((evt, cancellationToken) => + pluginHost.findReferences(evt.textDocument, evt.position, evt.context, cancellationToken) ); connection.onCodeAction((evt, cancellationToken) => @@ -460,8 +463,8 @@ export function startServer(options?: LSOptions) { pluginHost.getSelectionRanges(evt.textDocument, evt.positions) ); - connection.onImplementation((evt) => - pluginHost.getImplementation(evt.textDocument, evt.position) + connection.onImplementation((evt, cancellationToken) => + pluginHost.getImplementation(evt.textDocument, evt.position, cancellationToken) ); connection.onTypeDefinition((evt) => @@ -470,6 +473,17 @@ export function startServer(options?: LSOptions) { connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument)); + connection.onCodeLens((evt) => pluginHost.getCodeLens(evt.textDocument)); + connection.onCodeLensResolve((codeLens, token) => { + const data = codeLens.data as TextDocumentIdentifier; + + if (!data) { + return codeLens; + } + + return pluginHost.resolveCodeLens(data, codeLens, token); + }); + const diagnosticsManager = new DiagnosticsManager( connection.sendDiagnostics, docManager, diff --git a/packages/language-server/test/plugins/typescript/features/CodeLensProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CodeLensProvider.test.ts new file mode 100644 index 000000000..95459ddf6 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/CodeLensProvider.test.ts @@ -0,0 +1,209 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import ts from 'typescript'; +import { Document, DocumentManager } from '../../../../src/lib/documents'; +import { LSConfigManager } from '../../../../src/ls-config'; +import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDocResolver'; +import { CodeLensProviderImpl } from '../../../../src/plugins/typescript/features/CodeLensProvider'; +import { FindComponentReferencesProviderImpl } from '../../../../src/plugins/typescript/features/FindComponentReferencesProvider'; +import { FindReferencesProviderImpl } from '../../../../src/plugins/typescript/features/FindReferencesProvider'; +import { ImplementationProviderImpl } from '../../../../src/plugins/typescript/features/ImplementationProvider'; +import { pathToUrl } from '../../../../src/utils'; +import { serviceWarmup } from '../test-utils'; + +const testDir = path.join(__dirname, '..'); + +describe('CodeLensProvider', function () { + serviceWarmup(this, path.join(testDir, 'testfiles', 'codelens'), pathToUrl(testDir)); + + function getFullPath(filename: string) { + return path.join(testDir, 'testfiles', 'codelens', filename); + } + + function getUri(filename: string) { + return pathToUrl(getFullPath(filename)); + } + + function setup(filename: string) { + const docManager = new DocumentManager( + (textDocument) => new Document(textDocument.uri, textDocument.text) + ); + const lsConfigManager = new LSConfigManager(); + const lsAndTsDocResolver = new LSAndTSDocResolver( + docManager, + [pathToUrl(testDir)], + lsConfigManager + ); + const componentReferencesProvider = new FindComponentReferencesProviderImpl( + lsAndTsDocResolver + ); + const referenceProvider = new FindReferencesProviderImpl( + lsAndTsDocResolver, + componentReferencesProvider + ); + const implementationProvider = new ImplementationProviderImpl(lsAndTsDocResolver); + const provider = new CodeLensProviderImpl( + lsAndTsDocResolver, + referenceProvider, + implementationProvider, + lsConfigManager + ); + const filePath = getFullPath(filename); + const document = docManager.openClientDocument({ + uri: pathToUrl(filePath), + text: ts.sys.readFile(filePath) || '' + }); + return { provider, document, lsConfigManager }; + } + + it('provides reference codelens', async () => { + const { provider, document, lsConfigManager } = setup('references.svelte'); + + lsConfigManager.updateTsJsUserPreferences({ + typescript: { referencesCodeLens: { enabled: true } }, + javascript: {} + }); + + const codeLenses = await provider.getCodeLens(document); + + const references = codeLenses?.filter((lens) => lens.data.type === 'reference'); + + assert.deepStrictEqual(references, [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 } + }, + data: { type: 'reference', uri: getUri('references.svelte') } + }, + { + range: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 17 } + }, + data: { type: 'reference', uri: getUri('references.svelte') } + }, + { + range: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 11 } + }, + data: { type: 'reference', uri: getUri('references.svelte') } + } + ]); + }); + + it('resolve reference codelens', async () => { + const { provider, document } = setup('references.svelte'); + const codeLens = await provider.resolveCodeLens(document, { + range: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 17 } + }, + data: { type: 'reference', uri: getUri('references.svelte') } + }); + + assert.deepStrictEqual(codeLens.command, { + title: '1 reference', + command: '', + arguments: [ + getUri('references.svelte'), + { line: 1, character: 14 }, + [ + { + uri: getUri('references.svelte'), + range: { + start: { line: 5, character: 13 }, + end: { line: 5, character: 16 } + } + } + ] + ] + }); + }); + + it('resolve component reference codelens', async () => { + const { provider, document } = setup('references.svelte'); + const codeLens = await provider.resolveCodeLens(document, { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 } + }, + data: { type: 'reference', uri: getUri('references.svelte') } + }); + + assert.deepStrictEqual(codeLens.command, { + title: '2 references', + command: '', + arguments: [ + getUri('references.svelte'), + { line: 0, character: 0 }, + [ + { + uri: getUri('importing.svelte'), + range: { + start: { line: 1, character: 11 }, + end: { line: 1, character: 21 } + } + }, + { + uri: getUri('importing.svelte'), + range: { start: { line: 4, character: 1 }, end: { line: 4, character: 11 } } + } + ] + ] + }); + }); + + it('provides implementation codelens', async () => { + const { provider, document, lsConfigManager } = setup('references.svelte'); + + lsConfigManager.updateTsJsUserPreferences({ + typescript: { implementationsCodeLens: { enabled: true } }, + javascript: {} + }); + + const codeLenses = await provider.getCodeLens(document); + + const references = codeLenses?.filter((lens) => lens.data.type === 'implementation'); + + assert.deepStrictEqual(references, [ + { + range: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 17 } + }, + data: { type: 'implementation', uri: getUri('references.svelte') } + } + ]); + }); + + it('resolve implementation codelens', async () => { + const { provider, document } = setup('references.svelte'); + const codeLens = await provider.resolveCodeLens(document, { + range: { + start: { line: 1, character: 14 }, + end: { line: 1, character: 17 } + }, + data: { type: 'implementation', uri: getUri('references.svelte') } + }); + + assert.deepStrictEqual(codeLens.command, { + title: '1 implementation', + command: '', + arguments: [ + getUri('references.svelte'), + { line: 1, character: 14 }, + [ + { + uri: getUri('references.svelte'), + range: { + start: { line: 5, character: 19 }, + end: { line: 5, character: 33 } + } + } + ] + ] + }); + }); +}); diff --git a/packages/language-server/test/plugins/typescript/testfiles/codelens/importing.svelte b/packages/language-server/test/plugins/typescript/testfiles/codelens/importing.svelte new file mode 100644 index 000000000..bd6b4c514 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/codelens/importing.svelte @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/testfiles/codelens/references.svelte b/packages/language-server/test/plugins/typescript/testfiles/codelens/references.svelte new file mode 100644 index 000000000..b45c05a78 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/codelens/references.svelte @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/svelte-vscode/src/extension.ts b/packages/svelte-vscode/src/extension.ts index 41c607b6a..d8ae0e166 100644 --- a/packages/svelte-vscode/src/extension.ts +++ b/packages/svelte-vscode/src/extension.ts @@ -32,6 +32,7 @@ import { TsPlugin } from './tsplugin'; import { addFindComponentReferencesListener } from './typescript/findComponentReferences'; import { addFindFileReferencesListener } from './typescript/findFileReferences'; import { setupSvelteKit } from './sveltekit'; +import { resolveCodeLensMiddleware } from './middlewares'; namespace TagCloseRequest { export const type: RequestType = new RequestType( @@ -180,6 +181,9 @@ export function activateSvelteLanguageServer(context: ExtensionContext) { }, dontFilterIncompleteCompletions: true, // VSCode filters client side and is smarter at it than us isTrusted: workspace.isTrusted + }, + middleware: { + resolveCodeLens: resolveCodeLensMiddleware } }; diff --git a/packages/svelte-vscode/src/middlewares.ts b/packages/svelte-vscode/src/middlewares.ts new file mode 100644 index 000000000..69d309663 --- /dev/null +++ b/packages/svelte-vscode/src/middlewares.ts @@ -0,0 +1,43 @@ +import { Location, Range, Uri } from 'vscode'; +import { Middleware, Location as LSLocation } from 'vscode-languageclient'; + +/** + * Reference-like code lens require a client command to be executed. + * There isn't a way to request client to show references from the server. + * If other clients want to show references, they need to have a similar middleware to resolve the code lens. + */ +export const resolveCodeLensMiddleware: Middleware['resolveCodeLens'] = async function ( + resolving, + token, + next +) { + const codeLen = await next(resolving, token); + if (!codeLen) { + return resolving; + } + + if (codeLen.command?.arguments?.length !== 3) { + return codeLen; + } + + const locations = codeLen.command.arguments[2] as LSLocation[]; + codeLen.command.command = locations.length > 0 ? 'editor.action.showReferences' : ''; + codeLen.command.arguments = [ + Uri.parse(codeLen?.command?.arguments[0]), + codeLen.range.start, + locations.map( + (l) => + new Location( + Uri.parse(l.uri), + new Range( + l.range.start.line, + l.range.start.character, + l.range.end.line, + l.range.end.character + ) + ) + ) + ]; + + return codeLen; +};