Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/language-server/src/lib/documents/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Document,
getNodeIfIsInHTMLStartTag,
getNodeIfIsInStartTag,
getNodeIfIsInTagName,
getWordRangeAt,
isInTag,
mapCompletionItemToOriginal,
Expand All @@ -45,6 +46,7 @@ import { getJsDocTemplateCompletion } from './getJsDocTemplateCompletion';
import {
checkRangeMappingWithGeneratedSemi,
getComponentAtPosition,
getCustomElementsSymbols,
getFormatCodeBasis,
getNewScriptStartTag,
isKitTypePath,
Expand Down Expand Up @@ -544,87 +546,32 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionRe
position: Position
): CompletionItem[] | undefined {
const offset = document.offsetAt(position);
const tag = getNodeIfIsInHTMLStartTag(document.html, offset);
const tag = getNodeIfIsInTagName(document.html, offset);

if (!tag) {
if (!tag || !tag.tag) {
return;
}

const tagNameEnd = tag.start + 1 + (tag.tag?.length ?? 0);
if (offset > 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!)
);
Copy link
Author

@ouvreboite ouvreboite Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before, only the CompletionProvider was checking the custom elements, and not the HoverProvider. So I moved the logic that extracts the custom "types" to utils.ts to share across both


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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
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<Hover | null> {
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) {
return eventHoverInfo;
}

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,
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions packages/language-server/src/plugins/typescript/features/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,10 @@ describe('CompletionProviderImpl', function () {

assert.deepStrictEqual(item, <CompletionItem>{
label: 'custom-element',
documentation: {
value: 'Custom doc for custom element',
kind: 'markdown'
},
kind: CompletionItemKind.Property,
commitCharacters: [],
textEdit: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)), <Hover>{
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)), <Hover>{
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
declare global {
namespace svelteHTML {
interface IntrinsicElements {
'custom-element': any;
/** Custom doc for custom element */
'custom-element': {
/** bar */
foo: string
};
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
</script>

<HoverEventsInterface on:abc="{e => e}" />
<custom-element foo="bar"></custom-element>