@@ -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' || ! / l i t / 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 - Z a - z 0 - 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