diff --git a/packages/language-server/src/lib/documents/utils.ts b/packages/language-server/src/lib/documents/utils.ts index 374fdb07a..c91ad9d6d 100644 --- a/packages/language-server/src/lib/documents/utils.ts +++ b/packages/language-server/src/lib/documents/utils.ts @@ -340,6 +340,21 @@ export function getNodeIfIsInHTMLStartTag(html: HTMLDocument, offset: number): N } } +/** + * Returns the node if offset is on the actual tag name (start or end) + */ +export function getNodeIfIsInTagName(html: HTMLDocument, offset: number): Node | undefined { + const node = html.findNodeAt(offset); + if ( + !!node.tag && + node.tag[0] === node.tag[0].toLowerCase() && + (offset <= node.start + 1 + (node.tag.length ?? 0) || + (node.endTagStart && offset >= node.endTagStart && offset <= node.end)) + ) { + return node; + } +} + /** * Returns the node if offset is inside a starttag (HTML or component) */ diff --git a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts index b19f48967..09574a140 100644 --- a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts @@ -19,6 +19,7 @@ import { Document, getNodeIfIsInHTMLStartTag, getNodeIfIsInStartTag, + getNodeIfIsInTagName, getWordRangeAt, isInTag, mapCompletionItemToOriginal, @@ -45,6 +46,7 @@ import { getJsDocTemplateCompletion } from './getJsDocTemplateCompletion'; import { checkRangeMappingWithGeneratedSemi, getComponentAtPosition, + getCustomElementsSymbols, getFormatCodeBasis, getNewScriptStartTag, isKitTypePath, @@ -544,87 +546,32 @@ export class CompletionsProviderImpl implements CompletionsProvider tagNameEnd) { - return; - } - - const program = lang.getProgram(); - const sourceFile = program?.getSourceFile(tsDoc.filePath); - const typeChecker = program?.getTypeChecker(); - if (!typeChecker || !sourceFile) { - return; - } - - const typingsNamespace = lsContainer.getTsConfigSvelteOptions().namespace; - - const typingsNamespaceSymbol = this.findTypingsNamespaceSymbol( - typingsNamespace, - typeChecker, - sourceFile + let tags = getCustomElementsSymbols(lang, lsContainer, tsDoc).filter((t) => + t.name.startsWith(tag.tag!) ); - if (!typingsNamespaceSymbol) { - return; - } - - const elements = typeChecker - .getExportsOfModule(typingsNamespaceSymbol) - .find((symbol) => symbol.name === 'IntrinsicElements'); - - if (!elements || !(elements.flags & ts.SymbolFlags.Interface)) { - return; - } - - let tagNames: string[] = typeChecker - .getDeclaredTypeOfSymbol(elements) - .getProperties() - .map((p) => ts.symbolName(p)); - - if (tagNames.length && tag.tag) { - tagNames = tagNames.filter((name) => name.startsWith(tag.tag ?? '')); - } - + const tagNameEnd = tag.start + 1 + (tag.tag?.length ?? 0); const replacementRange = toRange(document, tag.start + 1, tagNameEnd); - return tagNames.map((name) => ({ - label: name, - kind: CompletionItemKind.Property, - textEdit: TextEdit.replace(cloneRange(replacementRange), name), - commitCharacters: [] - })); - } - - private findTypingsNamespaceSymbol( - namespaceExpression: string, - typeChecker: ts.TypeChecker, - sourceFile: ts.SourceFile - ) { - if (!namespaceExpression || typeof namespaceExpression !== 'string') { - return; - } - - const [first, ...rest] = namespaceExpression.split('.'); - - let symbol: ts.Symbol | undefined = typeChecker - .getSymbolsInScope(sourceFile, ts.SymbolFlags.Namespace) - .find((symbol) => symbol.name === first); - - for (const part of rest) { - if (!symbol) { - return; - } - - symbol = typeChecker.getExportsOfModule(symbol).find((symbol) => symbol.name === part); - } - - return symbol; + return tags.map( + (t) => + ({ + label: t.name, + documentation: { + value: t.documentation, + kind: 'markdown' + }, + kind: CompletionItemKind.Property, + textEdit: TextEdit.replace(cloneRange(replacementRange), t.name), + commitCharacters: [] + }) as CompletionItem + ); } private componentInfoToCompletionEntry( diff --git a/packages/language-server/src/plugins/typescript/features/HoverProvider.ts b/packages/language-server/src/plugins/typescript/features/HoverProvider.ts index bb6635f87..03cfeb1f3 100644 --- a/packages/language-server/src/plugins/typescript/features/HoverProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/HoverProvider.ts @@ -1,18 +1,24 @@ import ts from 'typescript'; import { Hover, Position } from 'vscode-languageserver'; -import { Document, getWordAt, mapObjWithRangeToOriginal } from '../../../lib/documents'; +import { + Document, + getNodeIfIsInTagName, + getWordAt, + mapObjWithRangeToOriginal +} from '../../../lib/documents'; import { HoverProvider } from '../../interfaces'; import { SvelteDocumentSnapshot } from '../DocumentSnapshot'; import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; import { getMarkdownDocumentation } from '../previewer'; import { convertRange } from '../utils'; -import { getComponentAtPosition } from './utils'; +import { getComponentAtPosition, getCustomElementsSymbols } from './utils'; +import { LanguageServiceContainer } from '../service'; export class HoverProviderImpl implements HoverProvider { constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} async doHover(document: Document, position: Position): Promise { - const { lang, tsDoc, userPreferences } = await this.getLSAndTSDoc(document); + const { lang, lsContainer, tsDoc, userPreferences } = await this.getLSAndTSDoc(document); const eventHoverInfo = this.getEventHoverInfo(lang, document, tsDoc, position); if (eventHoverInfo) { @@ -20,6 +26,23 @@ export class HoverProviderImpl implements HoverProvider { } const offset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)); + + const customElementDescription = this.getCustomElementDescription( + lang, + position, + lsContainer, + document, + tsDoc + ); + if (customElementDescription) { + return { + contents: { + value: customElementDescription, + kind: 'markdown' + } + }; + } + const info = lang.getQuickInfoAtPosition( tsDoc.filePath, offset, @@ -52,6 +75,25 @@ export class HoverProviderImpl implements HoverProvider { }); } + private getCustomElementDescription( + lang: ts.LanguageService, + position: Position, + lsContainer: LanguageServiceContainer, + document: Document, + tsDoc: SvelteDocumentSnapshot + ): string | undefined { + const offset = document.offsetAt(position); + const tag = getNodeIfIsInTagName(document.html, offset); + if (!tag || !tag.tag) { + return; + } + + const customElementsSymbols = getCustomElementsSymbols(lang, lsContainer, tsDoc); + let customElement = customElementsSymbols.find((t) => t.name == tag.tag!); + + return customElement?.documentation; + } + private getEventHoverInfo( lang: ts.LanguageService, doc: Document, diff --git a/packages/language-server/src/plugins/typescript/features/utils.ts b/packages/language-server/src/plugins/typescript/features/utils.ts index 954ef03bc..d444e7992 100644 --- a/packages/language-server/src/plugins/typescript/features/utils.ts +++ b/packages/language-server/src/plugins/typescript/features/utils.ts @@ -451,3 +451,74 @@ export function checkRangeMappingWithGeneratedSemi( originalRange.end.character += 1; } } + +/** Returns the list of registered custom elements and their description (from JSDoc) */ +export function getCustomElementsSymbols( + lang: ts.LanguageService, + lsContainer: LanguageServiceContainer, + tsDoc: SvelteDocumentSnapshot +): { name: string; documentation: string }[] { + const program = lang.getProgram(); + const sourceFile = program?.getSourceFile(tsDoc.filePath); + const typeChecker = program?.getTypeChecker(); + if (!typeChecker || !sourceFile) { + return []; + } + + const typingsNamespace = lsContainer.getTsConfigSvelteOptions().namespace; + + const typingsNamespaceSymbol = findTypingsNamespaceSymbol( + typingsNamespace, + typeChecker, + sourceFile + ); + + if (!typingsNamespaceSymbol) { + return []; + } + + const elements = typeChecker + .getExportsOfModule(typingsNamespaceSymbol) + .find((symbol) => symbol.name === 'IntrinsicElements'); + + if (!elements || !(elements.flags & ts.SymbolFlags.Interface)) { + return []; + } + + return typeChecker + .getDeclaredTypeOfSymbol(elements) + .getProperties() + .filter((s) => ts.symbolName(s).includes('-')) + .map((s) => { + return { + name: ts.symbolName(s), + documentation: ts.displayPartsToString(s.getDocumentationComment(typeChecker)) + }; + }); +} + +function findTypingsNamespaceSymbol( + namespaceExpression: string, + typeChecker: ts.TypeChecker, + sourceFile: ts.SourceFile +) { + if (!namespaceExpression || typeof namespaceExpression !== 'string') { + return; + } + + const [first, ...rest] = namespaceExpression.split('.'); + + let symbol: ts.Symbol | undefined = typeChecker + .getSymbolsInScope(sourceFile, ts.SymbolFlags.Namespace) + .find((symbol) => symbol.name === first); + + for (const part of rest) { + if (!symbol) { + return; + } + + symbol = typeChecker.getExportsOfModule(symbol).find((symbol) => symbol.name === part); + } + + return symbol; +} 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 f0f3ea933..e2ecda072 100644 --- a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts @@ -297,6 +297,10 @@ describe('CompletionProviderImpl', function () { assert.deepStrictEqual(item, { label: 'custom-element', + documentation: { + value: 'Custom doc for custom element', + kind: 'markdown' + }, kind: CompletionItemKind.Property, commitCharacters: [], textEdit: { diff --git a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts index 49e146108..af3a75b7f 100644 --- a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts @@ -193,6 +193,35 @@ describe('HoverProvider', function () { }); }); + it('provides formatted hover info for custom elements', async () => { + const { provider, document } = setup('hoverinfo.svelte'); + + assert.deepStrictEqual(await provider.doHover(document, Position.create(13, 7)), { + contents: { + value: 'Custom doc for custom element', + kind: 'markdown' + } + }); + }); + + it('provides formatted hover info for custom elements properties', async () => { + const { provider, document } = setup('hoverinfo.svelte'); + + assert.deepStrictEqual(await provider.doHover(document, Position.create(13, 18)), { + contents: '```typescript\n(property) foo: string\n```\n---\nbar', + range: { + end: { + character: 19, + line: 13 + }, + start: { + character: 16, + line: 13 + } + } + }); + }); + // Hacky, but it works. Needed due to testing both new and old transformation after(() => { __resetCache(); diff --git a/packages/language-server/test/plugins/typescript/testfiles/completions/customElement.d.ts b/packages/language-server/test/plugins/typescript/testfiles/completions/customElement.d.ts index 7ae751f18..33a720898 100644 --- a/packages/language-server/test/plugins/typescript/testfiles/completions/customElement.d.ts +++ b/packages/language-server/test/plugins/typescript/testfiles/completions/customElement.d.ts @@ -1,7 +1,11 @@ declare global { namespace svelteHTML { interface IntrinsicElements { - 'custom-element': any; + /** Custom doc for custom element */ + 'custom-element': { + /** bar */ + foo: string + }; } } } diff --git a/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte b/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte index af71608e2..a5231db72 100644 --- a/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte +++ b/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte @@ -11,3 +11,4 @@ +