Skip to content

Commit 306bcb4

Browse files
committed
feat(ts-plugin): type-check <webjs-tag attr=\${expr}> interpolations
Extend the plugin with a fourth resolver: for every \${expr} interpolation in attribute-value position of a reachable webjs tag, look up the matching `declare attr: T` field on the component class and assignability-check the expression's type against T using the TypeScript type checker. This works for custom types end-to-end — interfaces (`Student`), type aliases, string-literal unions ('login' | 'signup'), generics, and anything else `checker.isTypeAssignableTo` understands. Static attribute values like `mode=123` are deliberately NOT checked: at runtime they're plain template text (the string "123"), so flagging them would be a false positive. Only \${expr} carries a real value type worth checking. Behaviour matrix: - Reachable tag, prop has `declare attr: T`, expr type fits T → silent. - Reachable tag, prop has `declare attr: T`, expr type doesn't fit → diagnostic: "Type 'X' is not assignable to attribute 'Y' of type 'Z' on <foo-bar>." Code 9001, source 'webjskit-ts-plugin'. - Reachable tag but prop has no `declare` (i.e. type is unknown) → skip; no noise. The convention check covers this separately. - Unreachable tag → skip; ts-lit-plugin's "unknown tag" warning surfaces the missing import on its own. Bump @webjskit/ts-plugin → 0.3.0 (new feature). 6 new tests cover number-vs-string mismatch, assignable string, string-literal-union mismatch, static-text not flagged, no-declare skip, unreachable skip.
1 parent 4ef105f commit 306bcb4

3 files changed

Lines changed: 342 additions & 4 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.2.0",
3+
"version": "0.3.0",
44
"type": "commonjs",
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').",
5+
"description": "TypeScript language-service plugin for webjs — go-to-definition + ts-lit-plugin diagnostic suppression + attribute auto-complete + attribute-value type-check (against `declare propName: T`) 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: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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-zA-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-Za-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

8221014
module.exports = init;

test/ts-plugin.test.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,152 @@ test('completes static-properties keys after typing `<webjs-tag `', () => {
498498
assert.ok(names.includes('then'), `expected "then" in ${JSON.stringify(names)}`);
499499
});
500500

501+
/* ================================================================
502+
* Attribute-value type-check on `<webjs-tag attr=${expr}>` interpolations
503+
* ================================================================ */
504+
505+
test('flags number passed where string is declared', () => {
506+
const svc = makeService({
507+
'/auth.ts':
508+
`import { WebComponent } from '@webjskit/core';\n` +
509+
`export class AuthForms extends WebComponent {\n` +
510+
` static properties = { mode: { type: String } };\n` +
511+
` declare mode: string;\n` +
512+
`}\n` +
513+
`AuthForms.register('auth-forms');\n`,
514+
'/page.ts':
515+
`import { html } from '@webjskit/core';\n` +
516+
`import './auth.ts';\n` +
517+
`const x: number = 42;\n` +
518+
`export default function P() {\n` +
519+
` return html\`<auth-forms mode=\${x}></auth-forms>\`;\n` +
520+
`}\n`,
521+
});
522+
const diags = svc.getSemanticDiagnostics('/page.ts');
523+
const ours = diags.filter((d) => d.source === 'webjskit-ts-plugin');
524+
assert.equal(ours.length, 1, `expected one webjs diagnostic, got ${ours.length}`);
525+
const m = ours[0].messageText;
526+
assert.ok(/'number'/.test(m) && /'mode'/.test(m) && /'string'/.test(m),
527+
`unexpected message: ${m}`);
528+
});
529+
530+
test('passes when interpolated value is assignable to declared string type', () => {
531+
const svc = makeService({
532+
'/auth.ts':
533+
`import { WebComponent } from '@webjskit/core';\n` +
534+
`export class AuthForms extends WebComponent {\n` +
535+
` static properties = { mode: { type: String } };\n` +
536+
` declare mode: string;\n` +
537+
`}\n` +
538+
`AuthForms.register('auth-forms');\n`,
539+
'/page.ts':
540+
`import { html } from '@webjskit/core';\n` +
541+
`import './auth.ts';\n` +
542+
`const m: string = 'login';\n` +
543+
`export default function P() {\n` +
544+
` return html\`<auth-forms mode=\${m}></auth-forms>\`;\n` +
545+
`}\n`,
546+
});
547+
const diags = svc.getSemanticDiagnostics('/page.ts');
548+
const ours = diags.filter((d) => d.source === 'webjskit-ts-plugin');
549+
assert.equal(ours.length, 0, `unexpected diagnostics: ${ours.map((d) => d.messageText).join('; ')}`);
550+
});
551+
552+
test('flags string-or-number against a string-literal-union type', () => {
553+
const svc = makeService({
554+
'/auth.ts':
555+
`import { WebComponent } from '@webjskit/core';\n` +
556+
`type Mode = 'login' | 'signup';\n` +
557+
`export class AuthForms extends WebComponent {\n` +
558+
` static properties = { mode: { type: String } };\n` +
559+
` declare mode: Mode;\n` +
560+
`}\n` +
561+
`AuthForms.register('auth-forms');\n`,
562+
'/page.ts':
563+
`import { html } from '@webjskit/core';\n` +
564+
`import './auth.ts';\n` +
565+
`declare const x: string | number;\n` +
566+
`export default function P() {\n` +
567+
` return html\`<auth-forms mode=\${x}></auth-forms>\`;\n` +
568+
`}\n`,
569+
});
570+
const diags = svc.getSemanticDiagnostics('/page.ts');
571+
const ours = diags.filter((d) => d.source === 'webjskit-ts-plugin');
572+
assert.equal(ours.length, 1, `expected one diagnostic for string|number against Mode`);
573+
});
574+
575+
test('does not type-check static (non-interpolated) attribute values', () => {
576+
// <auth-forms mode=123> is plain template text — at runtime it's just
577+
// the string "123", not a number. We deliberately don't flag it.
578+
const svc = makeService({
579+
'/auth.ts':
580+
`import { WebComponent } from '@webjskit/core';\n` +
581+
`export class AuthForms extends WebComponent {\n` +
582+
` static properties = { mode: { type: String } };\n` +
583+
` declare mode: string;\n` +
584+
`}\n` +
585+
`AuthForms.register('auth-forms');\n`,
586+
'/page.ts':
587+
`import { html } from '@webjskit/core';\n` +
588+
`import './auth.ts';\n` +
589+
`export default function P() {\n` +
590+
` return html\`<auth-forms mode=123></auth-forms>\`;\n` +
591+
`}\n`,
592+
});
593+
const diags = svc.getSemanticDiagnostics('/page.ts');
594+
const ours = diags.filter((d) => d.source === 'webjskit-ts-plugin');
595+
assert.equal(ours.length, 0, 'static attribute value should not produce a webjs diagnostic');
596+
});
597+
598+
test('skips check when component is reachable but the prop has no `declare` annotation', () => {
599+
// If the user hasn't typed the prop, we can't check — fall back to
600+
// silence rather than noise.
601+
const svc = makeService({
602+
'/auth.ts':
603+
`import { WebComponent } from '@webjskit/core';\n` +
604+
`export class AuthForms extends WebComponent {\n` +
605+
` static properties = { mode: { type: String } };\n` +
606+
`}\n` + // no `declare mode: …`
607+
`AuthForms.register('auth-forms');\n`,
608+
'/page.ts':
609+
`import { html } from '@webjskit/core';\n` +
610+
`import './auth.ts';\n` +
611+
`declare const x: number;\n` +
612+
`export default function P() {\n` +
613+
` return html\`<auth-forms mode=\${x}></auth-forms>\`;\n` +
614+
`}\n`,
615+
});
616+
const diags = svc.getSemanticDiagnostics('/page.ts');
617+
const ours = diags.filter((d) => d.source === 'webjskit-ts-plugin');
618+
assert.equal(ours.length, 0, 'no declare → no check, no diagnostic');
619+
});
620+
621+
test('does not check tags that are not reachable through imports', () => {
622+
// The component class exists in the program but page.ts forgets to
623+
// import it. Reachability gating means we don't synthesise a value
624+
// diagnostic — the missing import is already surfaced via the kept
625+
// "unknown tag" warning from ts-lit-plugin.
626+
const svc = makeService({
627+
'/auth.ts':
628+
`import { WebComponent } from '@webjskit/core';\n` +
629+
`export class AuthForms extends WebComponent {\n` +
630+
` static properties = { mode: { type: String } };\n` +
631+
` declare mode: string;\n` +
632+
`}\n` +
633+
`AuthForms.register('auth-forms');\n`,
634+
'/page.ts':
635+
// No import './auth.ts'.
636+
`import { html } from '@webjskit/core';\n` +
637+
`declare const x: number;\n` +
638+
`export default function P() {\n` +
639+
` return html\`<auth-forms mode=\${x}></auth-forms>\`;\n` +
640+
`}\n`,
641+
});
642+
const diags = svc.getSemanticDiagnostics('/page.ts');
643+
const ours = diags.filter((d) => d.source === 'webjskit-ts-plugin');
644+
assert.equal(ours.length, 0, 'unreachable tag → no value-check (lit-plugin keeps its own "unknown tag" warning)');
645+
});
646+
501647
test('attribute completions are NOT offered when the component is not imported', () => {
502648
const svc = makeService({
503649
'/auth.ts':

0 commit comments

Comments
 (0)