Skip to content

Commit 25f9863

Browse files
committed
feat(ts-plugin): suppress lit-plugin "unknown tag/attr" + complete attrs
ts-lit-plugin doesn't recognise webjs components — they're registered at runtime via Class.register('tag') with no @CustomElement decorator or HTMLElementTagNameMap augmentation — so it fires "Unknown tag" / "Unknown attribute" diagnostics on every webjs element used inside html`` templates and offers no attribute completions. Editors lit up red; users had to manually add HTMLElementTagNameMap declarations. The webjs ts-plugin already extracts (tag → component class) from .register() / customElements.define() calls. Extend it to: 1. Filter ts-lit-plugin diagnostics whose span sits on, or inside an opener of, a webjs-known tag — but only when that tag is reachable through the file's import graph. An unreachable tag would also fail at runtime, so the warning must stay. 2. Read each component's `static properties = { … }` keys and offer them as completion entries when the cursor is in attribute slot of `<known-webjs-tag |>`. Bump @webjskit/ts-plugin → 0.2.0.
1 parent 1d7c5fd commit 25f9863

3 files changed

Lines changed: 474 additions & 3 deletions

File tree

packages/ts-plugin/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"name": "@webjskit/ts-plugin",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"type": "commonjs",
5-
"description": "TypeScript language-service plugin for webjs — go-to-definition for custom-element tag names inside html`` tagged templates.",
5+
"description": "TypeScript language-service plugin for webjs — go-to-definition + ts-lit-plugin diagnostic suppression + attribute auto-complete for custom elements registered via Class.register('tag').",
66
"main": "src/index.js",
77
"peerDependencies": {
88
"typescript": ">=5.0.0"

packages/ts-plugin/src/index.js

Lines changed: 265 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,239 @@ function init(modules) {
6060
}
6161
};
6262

63+
// ts-lit-plugin doesn't know about webjs components (no `@customElement`
64+
// decorator, no HTMLElementTagNameMap augmentation), so it flags every
65+
// `<my-component>` inside an html`` template as "Unknown tag". Filter
66+
// those out — but ONLY for tags that this file can actually reach
67+
// through its import graph. A tag registered somewhere in the program
68+
// but not imported here is still genuinely unknown at runtime, so the
69+
// diagnostic must stay.
70+
proxy.getSemanticDiagnostics = (fileName) => {
71+
const diags = inner.getSemanticDiagnostics(fileName);
72+
try { return filterLitTagDiagnostics(info, fileName, diags); }
73+
catch (e) {
74+
info.project.projectService.logger?.info?.(
75+
`@webjskit/ts-plugin: getSemanticDiagnostics threw: ${String(e)}`,
76+
);
77+
return diags;
78+
}
79+
};
80+
proxy.getSuggestionDiagnostics = (fileName) => {
81+
const diags = inner.getSuggestionDiagnostics(fileName);
82+
try { return filterLitTagDiagnostics(info, fileName, diags); }
83+
catch (e) { return diags; }
84+
};
85+
86+
// Attribute-name auto-complete inside `<webjs-tag |…>` openers. The
87+
// `static properties = { … }` map on the component class drives the
88+
// completion list. ts-lit-plugin's own completions kick in only when
89+
// it recognises the tag, which it doesn't for webjs.
90+
proxy.getCompletionsAtPosition = (fileName, position, options) => {
91+
const upstream = inner.getCompletionsAtPosition(fileName, position, options);
92+
try {
93+
const ours = webjsAttrCompletions(info, fileName, position);
94+
if (!ours || ours.length === 0) return upstream;
95+
if (!upstream) {
96+
return {
97+
isGlobalCompletion: false,
98+
isMemberCompletion: false,
99+
isNewIdentifierLocation: false,
100+
entries: ours,
101+
};
102+
}
103+
// De-dupe by name in case upstream and we both contributed the same
104+
// attribute (unlikely, but keep the IDE list clean).
105+
const seen = new Set(upstream.entries.map((e) => e.name));
106+
return {
107+
...upstream,
108+
entries: [...upstream.entries, ...ours.filter((e) => !seen.has(e.name))],
109+
};
110+
} catch (e) {
111+
info.project.projectService.logger?.info?.(
112+
`@webjskit/ts-plugin: getCompletionsAtPosition threw: ${String(e)}`,
113+
);
114+
return upstream;
115+
}
116+
};
117+
63118
return proxy;
64119
}
65120

121+
/* ================================================================
122+
* Diagnostic filter: drop ts-lit-plugin "unknown tag/attr" reports
123+
* for webjs components that are reachable from `fileName`.
124+
* ================================================================ */
125+
126+
/**
127+
* @param {import('typescript/lib/tsserverlibrary').server.PluginCreateInfo} info
128+
* @param {string} fileName
129+
* @param {readonly import('typescript').Diagnostic[] | undefined} diags
130+
*/
131+
function filterLitTagDiagnostics(info, fileName, diags) {
132+
if (!diags || diags.length === 0) return diags;
133+
const program = info.languageService.getProgram();
134+
if (!program) return diags;
135+
const sf = program.getSourceFile(fileName);
136+
if (!sf) return diags;
137+
138+
const registry = buildRegistry(program);
139+
if (registry.components.size === 0) return diags;
140+
const reachable = collectReachableTags(program, sf, registry);
141+
if (reachable.size === 0) return diags;
142+
143+
return diags.filter((d) => !shouldSuppressDiagnostic(d, sf, reachable));
144+
}
145+
146+
/**
147+
* A diagnostic is suppressible only if:
148+
* 1. It originates from ts-lit-plugin (source contains "lit"); and
149+
* 2. Its span sits on, or inside an opening tag whose name is, a
150+
* reachable webjs tag.
151+
*
152+
* @param {import('typescript').Diagnostic} d
153+
* @param {import('typescript').SourceFile} sf
154+
* @param {Set<string>} reachable
155+
*/
156+
function shouldSuppressDiagnostic(d, sf, reachable) {
157+
const source = /** @type any */ (d).source;
158+
if (typeof source !== 'string' || !/lit/i.test(source)) return false;
159+
if (typeof d.start !== 'number' || typeof d.length !== 'number') return false;
160+
const text = sf.text;
161+
// Case A: the span itself is the tag name.
162+
const spanText = text.slice(d.start, d.start + d.length).toLowerCase();
163+
if (reachable.has(spanText)) return true;
164+
// Case B: the span sits inside an opening tag whose name is reachable
165+
// (ts-lit-plugin "unknown attribute" diagnostics target the attribute
166+
// identifier, not the tag).
167+
const tag = enclosingOpenTag(text, d.start);
168+
return !!tag && reachable.has(tag);
169+
}
170+
171+
/**
172+
* Walk backwards from `pos` to find the nearest `<tag-name` opener that
173+
* has not yet been closed by `>`. Returns the lowercased tag name, or
174+
* undefined if the position is not inside an opening tag.
175+
*
176+
* @param {string} text
177+
* @param {number} pos
178+
*/
179+
function enclosingOpenTag(text, pos) {
180+
for (let i = pos - 1; i >= 0; i--) {
181+
const c = text[i];
182+
if (c === '>') return undefined;
183+
if (c !== '<') continue;
184+
// Found a `<`; read the tag name that follows.
185+
let j = i + 1;
186+
if (text[j] === '/') return undefined;
187+
let name = '';
188+
while (j < text.length) {
189+
const ch = text[j];
190+
if (/[A-Za-z0-9_-]/.test(ch)) { name += ch; j++; }
191+
else break;
192+
}
193+
if (!name || !name.includes('-')) return undefined;
194+
return name.toLowerCase();
195+
}
196+
return undefined;
197+
}
198+
199+
/**
200+
* Build the set of webjs tag names reachable from `entry` through its
201+
* (transitive) import graph. A tag is reachable if and only if the
202+
* file that registers it appears anywhere in entry's import closure
203+
* (entry counts as importing itself).
204+
*
205+
* @param {import('typescript').Program} program
206+
* @param {import('typescript').SourceFile} entry
207+
* @param {{ components: Map<string, ComponentRef> }} registry
208+
* @returns {Set<string>}
209+
*/
210+
function collectReachableTags(program, entry, registry) {
211+
const checker = program.getTypeChecker();
212+
/** @type {Map<string, string[]>} */
213+
const tagsByFile = new Map();
214+
for (const [tag, ref] of registry.components) {
215+
const arr = tagsByFile.get(ref.fileName) || [];
216+
arr.push(tag);
217+
tagsByFile.set(ref.fileName, arr);
218+
}
219+
220+
/** @type {Set<string>} */
221+
const visited = new Set();
222+
/** @type {Set<string>} */
223+
const tags = new Set();
224+
/** @type {string[]} */
225+
const stack = [entry.fileName];
226+
while (stack.length) {
227+
const fn = stack.pop();
228+
if (!fn || visited.has(fn)) continue;
229+
visited.add(fn);
230+
const arr = tagsByFile.get(fn);
231+
if (arr) for (const t of arr) tags.add(t);
232+
const sf = program.getSourceFile(fn);
233+
if (!sf) continue;
234+
for (const stmt of sf.statements) {
235+
const spec =
236+
ts.isImportDeclaration(stmt) ? stmt.moduleSpecifier
237+
: ts.isExportDeclaration(stmt) && stmt.moduleSpecifier ? stmt.moduleSpecifier
238+
: undefined;
239+
if (!spec || !ts.isStringLiteralLike(spec)) continue;
240+
const sym = checker.getSymbolAtLocation(spec);
241+
if (!sym || !sym.declarations) continue;
242+
for (const d of sym.declarations) {
243+
if (ts.isSourceFile(d)) stack.push(d.fileName);
244+
}
245+
}
246+
}
247+
return tags;
248+
}
249+
250+
/* ================================================================
251+
* Resolver 3: attribute-name completions inside `<webjs-tag …>`
252+
* ================================================================ */
253+
254+
/**
255+
* @param {import('typescript/lib/tsserverlibrary').server.PluginCreateInfo} info
256+
* @param {string} fileName
257+
* @param {number} position
258+
* @returns {import('typescript').CompletionEntry[] | undefined}
259+
*/
260+
function webjsAttrCompletions(info, fileName, position) {
261+
const program = info.languageService.getProgram();
262+
if (!program) return undefined;
263+
const source = program.getSourceFile(fileName);
264+
if (!source) return undefined;
265+
266+
// Must be inside an html`` template, in an opening-tag attribute slot.
267+
const templateExpr = findEnclosingTaggedTemplate(source, position, 'html');
268+
if (!templateExpr) return undefined;
269+
const { rawText, startPos } = getTemplateText(templateExpr);
270+
const offset = position - startPos;
271+
if (offset < 0 || offset > rawText.length) return undefined;
272+
273+
const sanitised = stripHoles(rawText);
274+
const tag = enclosingOpenTag(sanitised, offset);
275+
if (!tag) return undefined;
276+
277+
const registry = buildRegistry(program);
278+
const ref = registry.components.get(tag);
279+
if (!ref || !ref.attributes || ref.attributes.length === 0) return undefined;
280+
281+
// Restrict to tags reachable from this file. Without the import,
282+
// suggesting attributes would imply the element is usable here when
283+
// it isn't.
284+
const reachable = collectReachableTags(program, source, registry);
285+
if (!reachable.has(tag)) return undefined;
286+
287+
return ref.attributes.map((name) => ({
288+
name,
289+
kind: /** @type any */ (ts.ScriptElementKind).memberVariableElement,
290+
kindModifiers: '',
291+
sortText: '0',
292+
labelDetails: { description: `<${tag}>` },
293+
}));
294+
}
295+
66296
/* ================================================================
67297
* Resolver 1: custom-element tag → component class
68298
* ================================================================ */
@@ -356,6 +586,7 @@ function init(modules) {
356586
* fileName: string,
357587
* className: string,
358588
* classNameSpan: import('typescript').TextSpan,
589+
* attributes: string[],
359590
* }} ComponentRef
360591
*
361592
* @typedef {{
@@ -422,7 +653,7 @@ function init(modules) {
422653
/** @type {Map<string, ComponentRef>} */
423654
const out = new Map();
424655

425-
/** @type {Map<string, { span: import('typescript').TextSpan }>} */
656+
/** @type {Map<string, { span: import('typescript').TextSpan, attrs: string[] }>} */
426657
const localClasses = new Map();
427658
function indexClasses(node) {
428659
if (ts.isClassDeclaration(node) && node.name) {
@@ -431,6 +662,7 @@ function init(modules) {
431662
start: node.name.getStart(sf),
432663
length: node.name.getWidth(sf),
433664
},
665+
attrs: extractStaticProperties(node),
434666
});
435667
}
436668
ts.forEachChild(node, indexClasses);
@@ -447,6 +679,7 @@ function init(modules) {
447679
fileName: sf.fileName,
448680
className: match.className,
449681
classNameSpan: local.span,
682+
attributes: local.attrs,
450683
});
451684
}
452685
}
@@ -457,6 +690,37 @@ function init(modules) {
457690
return out;
458691
}
459692

693+
/**
694+
* Read the keys of a class's `static properties = { … }` initializer.
695+
* webjs maps each key to a reactive property + matching attribute, so
696+
* the keys are exactly the attribute set we want to suggest.
697+
*
698+
* @param {import('typescript').ClassDeclaration} cls
699+
* @returns {string[]}
700+
*/
701+
function extractStaticProperties(cls) {
702+
/** @type {string[]} */
703+
const out = [];
704+
for (const member of cls.members) {
705+
if (!ts.isPropertyDeclaration(member)) continue;
706+
const isStatic = (member.modifiers || []).some(
707+
(m) => m.kind === ts.SyntaxKind.StaticKeyword,
708+
);
709+
if (!isStatic) continue;
710+
if (!member.name || !ts.isIdentifier(member.name) || member.name.text !== 'properties') continue;
711+
const init = member.initializer;
712+
if (!init || !ts.isObjectLiteralExpression(init)) continue;
713+
for (const prop of init.properties) {
714+
if (!prop.name) continue;
715+
let key;
716+
if (ts.isIdentifier(prop.name) || ts.isPrivateIdentifier(prop.name)) key = prop.name.text;
717+
else if (ts.isStringLiteralLike(prop.name)) key = prop.name.text;
718+
if (key) out.push(key);
719+
}
720+
}
721+
return out;
722+
}
723+
460724
/**
461725
* Extract CSS class definitions from every `css\`…\`` tagged template in
462726
* the file. Each occurrence of `.class-name` in the template text is

0 commit comments

Comments
 (0)