diff --git a/packages/language-server/src/plugins/typescript/ComponentInfoProvider.ts b/packages/language-server/src/plugins/typescript/ComponentInfoProvider.ts new file mode 100644 index 000000000..1114fa80d --- /dev/null +++ b/packages/language-server/src/plugins/typescript/ComponentInfoProvider.ts @@ -0,0 +1,72 @@ +import { ComponentEvents } from 'svelte2tsx'; +import ts from 'typescript'; +import { isNotNullOrUndefined } from '../../utils'; +import { findContainingNode } from './features/utils'; + +type ComponentEventInfo = ReturnType; + +export class JsOrTsComponentInfoProvider implements ComponentInfoProvider { + private constructor( + private readonly typeChecker: ts.TypeChecker, + private readonly classType: ts.Type + ) {} + + getEvents(): ComponentEventInfo { + const symbol = this.classType.getProperty('$$events_def'); + if (!symbol) { + return []; + } + + const declaration = symbol.valueDeclaration; + if (!declaration) { + return []; + } + + const eventType = this.typeChecker.getTypeOfSymbolAtLocation(symbol, declaration); + + return eventType + .getProperties() + .map((prop) => { + if (!prop.valueDeclaration) { + return; + } + + return { + name: prop.name, + type: this.typeChecker.typeToString( + this.typeChecker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration) + ), + doc: ts.displayPartsToString(prop.getDocumentationComment(this.typeChecker)) + }; + }) + .filter(isNotNullOrUndefined); + } + + static create(lang: ts.LanguageService, def: ts.DefinitionInfo): ComponentInfoProvider | null { + const program = lang.getProgram(); + const sourceFile = program?.getSourceFile(def.fileName); + + if (!program || !sourceFile) { + return null; + } + + const defClass = findContainingNode(sourceFile, def.textSpan, ts.isClassDeclaration); + + if (!defClass) { + return null; + } + + const typeChecker = program.getTypeChecker(); + const classType = typeChecker.getTypeAtLocation(defClass); + + if (!classType) { + return null; + } + + return new JsOrTsComponentInfoProvider(typeChecker, classType); + } +} + +export interface ComponentInfoProvider { + getEvents(): ComponentEventInfo; +} diff --git a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts index b8762d880..d7c4f4d94 100644 --- a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts +++ b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts @@ -13,6 +13,7 @@ import { isInTag } from '../../lib/documents'; import { pathToUrl } from '../../utils'; +import { ComponentInfoProvider, JsOrTsComponentInfoProvider } from './ComponentInfoProvider'; import { ConsumerDocumentMapper } from './DocumentMapper'; import { getScriptKindFromAttributes, @@ -224,7 +225,7 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions /** * A svelte document snapshot suitable for the ts language service and the plugin. */ -export class SvelteDocumentSnapshot implements DocumentSnapshot { +export class SvelteDocumentSnapshot implements DocumentSnapshot, ComponentInfoProvider { private fragment?: SvelteSnapshotFragment; version = this.parent.version; @@ -325,6 +326,8 @@ export class JSOrTSDocumentSnapshot scriptKind = getScriptKindFromFileName(this.filePath); scriptInfo = null; + private readonly componentInfos = new Map(); + constructor(public version: number, public readonly filePath: string, private text: string) { super(pathToUrl(filePath)); } @@ -377,6 +380,21 @@ export class JSOrTSDocumentSnapshot this.version++; } + + getComponentInfo( + lang: ts.LanguageService, + def: ts.DefinitionInfo + ): ComponentInfoProvider | null { + // there might multiple component class in a js or ts file + if (this.componentInfos.has(def)) { + return this.componentInfos.get(def) ?? null; + } + + const componentInfoProvider = JsOrTsComponentInfoProvider.create(lang, def); + this.componentInfos.set(def, componentInfoProvider); + + return componentInfoProvider; + } } /** diff --git a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts index 5eefb9b39..551124bce 100644 --- a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts @@ -210,14 +210,14 @@ export class CompletionsProviderImpl implements CompletionsProvider>> { - const snapshot = await getComponentAtPosition( + const componentInfo = await getComponentAtPosition( this.lsAndTsDocResolver, lang, doc, tsDoc, originalPosition ); - if (!snapshot) { + if (!componentInfo) { return []; } @@ -227,7 +227,7 @@ export class CompletionsProviderImpl implements CompletionsProvider { + return componentInfo.getEvents().map((event) => { const eventName = 'on:' + event.name; return { label: eventName, diff --git a/packages/language-server/src/plugins/typescript/features/utils.ts b/packages/language-server/src/plugins/typescript/features/utils.ts index e26582fdf..d9db57e29 100644 --- a/packages/language-server/src/plugins/typescript/features/utils.ts +++ b/packages/language-server/src/plugins/typescript/features/utils.ts @@ -6,7 +6,13 @@ import { getNodeIfIsInComponentStartTag, isInTag } from '../../../lib/documents'; -import { DocumentSnapshot, SnapshotFragment, SvelteDocumentSnapshot } from '../DocumentSnapshot'; +import { ComponentInfoProvider } from '../ComponentInfoProvider'; +import { + DocumentSnapshot, + JSOrTSDocumentSnapshot, + SnapshotFragment, + SvelteDocumentSnapshot +} from '../DocumentSnapshot'; import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; /** @@ -14,12 +20,12 @@ import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; * return the snapshot of that component. */ export async function getComponentAtPosition( - lsAndTsDocResovler: LSAndTSDocResolver, + lsAndTsDocResolver: LSAndTSDocResolver, lang: ts.LanguageService, doc: Document, tsDoc: SvelteDocumentSnapshot, originalPosition: Position -): Promise { +): Promise { if (tsDoc.parserError) { return null; } @@ -47,11 +53,17 @@ export async function getComponentAtPosition( return null; } - const snapshot = await lsAndTsDocResovler.getSnapshot(def.fileName); - if (!(snapshot instanceof SvelteDocumentSnapshot)) { - return null; + const snapshot = await lsAndTsDocResolver.getSnapshot(def.fileName); + + if (snapshot instanceof SvelteDocumentSnapshot) { + return snapshot; } - return snapshot; + + if (snapshot instanceof JSOrTSDocumentSnapshot) { + return snapshot.getComponentInfo(lang, def); + } + + return null; } export function isComponentAtPosition( @@ -138,3 +150,27 @@ export function isAfterSvelte2TsxPropsReturn(text: string, end: number) { return true; } } + +export function findContainingNode( + node: ts.Node, + textSpan: ts.TextSpan, + predicate: (node: ts.Node) => node is T +): T | undefined { + const children = node.getChildren(); + const end = textSpan.start + textSpan.length; + + for (const child of children) { + if (!(child.getStart() <= textSpan.start && child.getEnd() >= end)) { + continue; + } + + if (predicate(child)) { + return child; + } + + const foundInChildren = findContainingNode(child, textSpan, predicate); + if (foundInChildren) { + return foundInChildren; + } + } +} diff --git a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts index 1ff362479..5a3e6bd91 100644 --- a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts @@ -123,7 +123,6 @@ describe('CompletionProviderImpl', () => { ); assert.ok(completions!.items.length > 0, 'Expected completions to have length'); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const eventCompletions = completions!.items.filter((item) => item.label.startsWith('on:')); assert.deepStrictEqual(eventCompletions, [ @@ -238,6 +237,64 @@ describe('CompletionProviderImpl', () => { ]); }); + it('provides event completion for components with type definition', async () => { + const { completionProvider, document } = setup('component-events-completion-ts-def.svelte'); + + const completions = await completionProvider.getCompletions( + document, + Position.create(4, 17), + { + triggerKind: CompletionTriggerKind.Invoked + } + ); + + const eventCompletions = completions!.items.filter((item) => item.label.startsWith('on:')); + + assert.deepStrictEqual(eventCompletions, [ + { + detail: 'event1: CustomEvent', + documentation: '', + label: 'on:event1', + sortText: '-1', + textEdit: { + newText: 'on:event1', + range: { + end: { + character: 17, + line: 4 + }, + start: { + character: 14, + line: 4 + } + } + } + }, + { + detail: 'event2: CustomEvent', + documentation: { + kind: 'markdown', + value: 'documentation for event2' + }, + label: 'on:event2', + sortText: '-1', + textEdit: { + newText: 'on:event2', + range: { + end: { + character: 17, + line: 4 + }, + start: { + character: 14, + line: 4 + } + } + } + } + ]); + }); + it('does not provide completions inside style tag', async () => { const { completionProvider, document } = setup('completionsstyle.svelte'); diff --git a/packages/language-server/test/plugins/typescript/testfiles/completions/ComponentDef.ts b/packages/language-server/test/plugins/typescript/testfiles/completions/ComponentDef.ts new file mode 100644 index 000000000..3bc3bdcdd --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/completions/ComponentDef.ts @@ -0,0 +1,14 @@ +/// +import { SvelteComponentTyped } from 'svelte'; + +export class ComponentDef extends SvelteComponentTyped< + {}, + { + event1: CustomEvent; + /** + * documentation for event2 + */ + event2: CustomEvent; + }, + {} +> {} diff --git a/packages/language-server/test/plugins/typescript/testfiles/completions/component-events-completion-ts-def.svelte b/packages/language-server/test/plugins/typescript/testfiles/completions/component-events-completion-ts-def.svelte new file mode 100644 index 000000000..d75856ea9 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/completions/component-events-completion-ts-def.svelte @@ -0,0 +1,5 @@ + + + \ No newline at end of file