@@ -69,8 +69,11 @@ function init(modules) {
6969 // diagnostic must stay.
7070 proxy . getSemanticDiagnostics = ( fileName ) => {
7171 const diags = inner . getSemanticDiagnostics ( fileName ) ;
72- try { return filterLitTagDiagnostics ( info , fileName , diags ) ; }
73- catch ( e ) {
72+ try {
73+ const filtered = filterLitTagDiagnostics ( info , fileName , diags ) ;
74+ const attrDiags = webjsAttrValueDiagnostics ( info , fileName ) ;
75+ return attrDiags . length ? [ ...filtered , ...attrDiags ] : filtered ;
76+ } catch ( e ) {
7477 info . project . projectService . logger ?. info ?. (
7578 `@webjskit/ts-plugin: getSemanticDiagnostics threw: ${ String ( e ) } ` ,
7679 ) ;
@@ -817,6 +820,195 @@ function init(modules) {
817820
818821 return { tag : tagArg . text , className : classArg . text } ;
819822 }
823+
824+ /* ================================================================
825+ * Resolver 4: type-check `<webjs-tag attr=${expr}>` interpolations
826+ * against the property's declared TypeScript type.
827+ * ================================================================ */
828+
829+ /**
830+ * Walk every html`` template in the file. For each `${expr}` that
831+ * sits in attribute-value position of a reachable webjs tag, look up
832+ * the matching `declare attr: T` field on the component class and
833+ * assignability-check `typeof expr` against `T`. Emit a diagnostic
834+ * for any mismatch.
835+ *
836+ * Static (non-interpolated) attribute values like `mode="login"` are
837+ * not checked — they're plain template text and at runtime always
838+ * coerce to strings. Only interpolations carry a real value type
839+ * worth checking.
840+ *
841+ * @param {import('typescript/lib/tsserverlibrary').server.PluginCreateInfo } info
842+ * @param {string } fileName
843+ * @returns {import('typescript').Diagnostic[] }
844+ */
845+ function webjsAttrValueDiagnostics ( info , fileName ) {
846+ /** @type {import('typescript').Diagnostic[] } */
847+ const out = [ ] ;
848+ const program = info . languageService . getProgram ( ) ;
849+ if ( ! program ) return out ;
850+ const sf = program . getSourceFile ( fileName ) ;
851+ if ( ! sf ) return out ;
852+
853+ const registry = buildRegistry ( program ) ;
854+ if ( registry . components . size === 0 ) return out ;
855+ const reachable = collectReachableTags ( program , sf , registry ) ;
856+ if ( reachable . size === 0 ) return out ;
857+
858+ const checker = program . getTypeChecker ( ) ;
859+
860+ /** @param {import('typescript').Node } node */
861+ function visit ( node ) {
862+ if ( ts . isTaggedTemplateExpression ( node ) && tagMatches ( node . tag , 'html' ) ) {
863+ collectFromTemplate ( node ) ;
864+ }
865+ ts . forEachChild ( node , visit ) ;
866+ }
867+
868+ /** @param {import('typescript').TaggedTemplateExpression } expr */
869+ function collectFromTemplate ( expr ) {
870+ const tpl = expr . template ;
871+ if ( ts . isNoSubstitutionTemplateLiteral ( tpl ) ) return ;
872+ // tpl is a TemplateExpression: head + spans[].
873+ const segments = [ tpl . head , ...tpl . templateSpans . map ( ( s ) => s . literal ) ] ;
874+ // segments[i].text is the cooked text *between* the (i-1)th hole and
875+ // the ith hole (segments[0] is the head, before the first hole).
876+ // Walk text segment-by-segment, tracking which interpolation each
877+ // hole belongs to.
878+ // Stitch the cooked text together with placeholders to track tags.
879+ // Simpler: just inspect the trailing text of each segment that
880+ // precedes a span — does it look like `<webjs-tag … attr=`?
881+ for ( let i = 0 ; i < tpl . templateSpans . length ; i ++ ) {
882+ // Text immediately preceding the i-th interpolation.
883+ const preceding = i === 0 ? tpl . head . text : tpl . templateSpans [ i - 1 ] . literal . text ;
884+ // Build the *full* preceding text for this interpolation (head +
885+ // all earlier segments). We need this so an opening `<` from a
886+ // previous segment is still visible. Use the cumulative slice
887+ // ending at segment `i`.
888+ const cumulative = i === 0
889+ ? preceding
890+ : segments . slice ( 0 , i + 1 ) . map ( ( s ) => s . text ) . join ( '•' ) ; // any non-tag char as placeholder
891+ const ctx = findAttrContext ( cumulative ) ;
892+ if ( ! ctx ) continue ;
893+ if ( ! reachable . has ( ctx . tag ) ) continue ;
894+ const ref = registry . components . get ( ctx . tag ) ;
895+ if ( ! ref ) continue ;
896+ // Skip if the attr name doesn't match a known prop.
897+ if ( ! ref . attributes . includes ( ctx . attr ) ) continue ;
898+
899+ const propType = resolvePropType ( program , ref , ctx . attr , checker ) ;
900+ if ( ! propType ) continue ; // no `declare` annotation → can't check
901+
902+ const span = tpl . templateSpans [ i ] ;
903+ const exprNode = span . expression ;
904+ const exprType = checker . getTypeAtLocation ( exprNode ) ;
905+
906+ if ( checker . isTypeAssignableTo ( exprType , propType ) ) continue ;
907+
908+ out . push ( {
909+ file : sf ,
910+ start : exprNode . getStart ( sf ) ,
911+ length : exprNode . getEnd ( ) - exprNode . getStart ( sf ) ,
912+ messageText :
913+ `Type '${ checker . typeToString ( exprType ) } ' is not assignable to ` +
914+ `attribute '${ ctx . attr } ' of type '${ checker . typeToString ( propType ) } ' on <${ ctx . tag } >.` ,
915+ category : ts . DiagnosticCategory . Error ,
916+ code : 9001 ,
917+ source : 'webjskit-ts-plugin' ,
918+ } ) ;
919+ }
920+ }
921+
922+ visit ( sf ) ;
923+ return out ;
924+ }
925+
926+ /**
927+ * Inspect the tail of `text` (cumulative html`` segments preceding an
928+ * interpolation) and return the enclosing tag + attribute name if the
929+ * interpolation sits in attribute-value position of an open tag.
930+ *
931+ * @param {string } text
932+ * @returns {{ tag: string, attr: string } | undefined }
933+ */
934+ function findAttrContext ( text ) {
935+ // Find the last unclosed `<`. We want the opener whose `>` hasn't
936+ // appeared yet.
937+ let depth = 0 ;
938+ let openIdx = - 1 ;
939+ for ( let i = 0 ; i < text . length ; i ++ ) {
940+ if ( text [ i ] === '<' ) { openIdx = i ; depth = 1 ; }
941+ else if ( text [ i ] === '>' && depth === 1 ) { depth = 0 ; openIdx = - 1 ; }
942+ }
943+ if ( openIdx === - 1 ) return undefined ;
944+ const tagPart = text . slice ( openIdx + 1 ) ;
945+ // First token after `<` is the tag name.
946+ const tm = / ^ ( [ a - z A - Z ] [ \w - ] * ) / . exec ( tagPart ) ;
947+ if ( ! tm ) return undefined ;
948+ const tag = tm [ 1 ] . toLowerCase ( ) ;
949+ if ( ! tag . includes ( '-' ) ) return undefined ;
950+ // Trailing pattern: ` attrName=` optionally followed by an open quote.
951+ const am = / \s + ( [ A - Z a - z _ ] [ \w - ] * ) \s * = \s * [ ' " ` ] ? $ / . exec ( tagPart ) ;
952+ if ( ! am ) return undefined ;
953+ return { tag, attr : am [ 1 ] } ;
954+ }
955+
956+ /**
957+ * Resolve the declared type of `attr` on the given component class.
958+ * Looks for a class member with that name and a TypeNode annotation
959+ * (typically a `declare attr: T` field). Returns undefined if no
960+ * annotation is present — the user hasn't told us the type, so we
961+ * can't check it.
962+ *
963+ * @param {import('typescript').Program } program
964+ * @param {ComponentRef } ref
965+ * @param {string } attrName
966+ * @param {import('typescript').TypeChecker } checker
967+ * @returns {import('typescript').Type | undefined }
968+ */
969+ function resolvePropType ( program , ref , attrName , checker ) {
970+ const compSf = program . getSourceFile ( ref . fileName ) ;
971+ if ( ! compSf ) return undefined ;
972+ const cls = findClassDeclaration ( compSf , ref . className ) ;
973+ if ( ! cls ) return undefined ;
974+ for ( const member of cls . members ) {
975+ if ( ! ts . isPropertyDeclaration ( member ) ) continue ;
976+ if ( ! member . name ) continue ;
977+ let memberName ;
978+ if ( ts . isIdentifier ( member . name ) || ts . isPrivateIdentifier ( member . name ) ) {
979+ memberName = member . name . text ;
980+ } else if ( ts . isStringLiteralLike ( member . name ) ) {
981+ memberName = member . name . text ;
982+ }
983+ if ( memberName !== attrName ) continue ;
984+ if ( ! member . type ) return undefined ;
985+ return checker . getTypeFromTypeNode ( member . type ) ;
986+ }
987+ return undefined ;
988+ }
989+
990+ /**
991+ * Locate `class <name> { … }` inside a source file. Returns the
992+ * ClassDeclaration node, or undefined if not found.
993+ *
994+ * @param {import('typescript').SourceFile } sf
995+ * @param {string } className
996+ * @returns {import('typescript').ClassDeclaration | undefined }
997+ */
998+ function findClassDeclaration ( sf , className ) {
999+ /** @type {import('typescript').ClassDeclaration | undefined } */
1000+ let found ;
1001+ function walk ( node ) {
1002+ if ( found ) return ;
1003+ if ( ts . isClassDeclaration ( node ) && node . name && node . name . text === className ) {
1004+ found = /** @type any */ ( node ) ;
1005+ return ;
1006+ }
1007+ ts . forEachChild ( node , walk ) ;
1008+ }
1009+ walk ( sf ) ;
1010+ return found ;
1011+ }
8201012}
8211013
8221014module . exports = init ;
0 commit comments