Skip to content
Merged
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
11 changes: 11 additions & 0 deletions parser/src/analysis-types/typescript/TsExpressionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ export class TsExpressionRegistry implements EntityIdentifiable {
this.potentialQualifiedName = potentialQualifiedName;
}

/**
* The tag of an intrinsic element (`div`, `svg:circle`, `my-el`), written as in the source.
*
* A component tag is a METHOD_NAME child that resolves; an intrinsic one references nothing, so its
* name has no child to live on and goes in this slot, with `referencedEntityKind` left empty. The
* engine's projections never read this column, so it cannot reach name resolution.
*/
setJsxTagName(tag: string): void {
this.potentialQualifiedName = tag;
}

getReferencedEntityHash(): string {
return this.referencedEntityHash;
}
Expand Down
15 changes: 13 additions & 2 deletions parser/src/enums/typescript/expressions/TsExpressionKind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
* A JSX element whose tag names a component IS a call to it, with the whole
* props object as argument 0: JSX_ELEMENT / JSX_SELF_CLOSING, 1:1 with a call
* site, with the tag as its METHOD_NAME child. Attribute values and children are
* rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element emits
* no row.
* rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element
* (`<div>`, `<my-el>`, `<svg:circle>`) is JSX_INTRINSIC_ELEMENT: a row with no
* children and no call site, so it exists only as the element its attributes
* belong to.
*
* Schema §4.14 c0, §4.15.1.
*/
Expand Down Expand Up @@ -99,6 +101,15 @@ export enum TsExpressionKind {
JSX_ELEMENT = 'JSX_ELEMENT',
/** `<Badge/>` naming a component. 1:1 with a call site. */
JSX_SELF_CLOSING = 'JSX_SELF_CLOSING',
/**
* `<div onClick={h}>`, `<my-el/>`, `<svg:circle/>` -- a tag that names no
* declaration. NOT a call: no call site, no METHOD_NAME child, so nothing can
* resolve it into `JSX.IntrinsicElements`. The row exists so that a plain
* tag's attributes (an event handler, a `ref` callback) have an element to
* belong to, as the JavaScript parser's JSX_INTRINSIC_ELEMENT does. The tag,
* as written (`div`, `svg:circle`, `my-el`), is its `potentialQualifiedName`.
*/
JSX_INTRINSIC_ELEMENT = 'JSX_INTRINSIC_ELEMENT',
/** `delete a`, `typeof a`, `void a` — `operatorString` says which. */
DELETE_TYPEOF_VOID = 'DELETE_TYPEOF_VOID',
/** `a, b` — the comma operator. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ import { isVueTemplateTag } from '@/utils/vue-sfc';
*
* A JSX element naming a component is a call (§4.15.1): a JSX_ELEMENT or
* JSX_SELF_CLOSING row with its tag as the METHOD_NAME child, and a
* JSX_COMPONENT_CALL site. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved:
* JSX_COMPONENT_CALL site. An intrinsic element (`<div>`) is a
* JSX_INTRINSIC_ELEMENT row with no child and no call site; its tag (`div`)
* is the row's `potentialQualifiedName`. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved:
* attribute values and children are rooted as their own trees by the walker, and
* the gate asserts the emptiness so that switching them on shows up as a failure.
*/
Expand Down Expand Up @@ -174,6 +176,9 @@ export class TsExpressionExtractor {
isTypeOnlyReachable: false,
serviceVersionLinkHash: this.options.serviceVersionLinkHash,
});
if (kind === TsExpressionKind.JSX_INTRINSIC_ELEMENT) {
row.setJsxTagName(jsxTagText(item.node, this.sf));
}
this.expressions.push(row);
this.rowByNode.set(nodeId(item.node, this.sf), row);
this.emitted.push({ node: item.node, row });
Expand Down Expand Up @@ -453,10 +458,12 @@ export function expressionKindOf(node: ts.Node): TsExpressionKind | undefined {
case ts.SyntaxKind.JsxElement:
case ts.SyntaxKind.JsxSelfClosingElement: {
// `<Badge/>` IS a call to Badge. `<div>` is an intrinsic element that no
// project declaration can bind, so it produces no row -- a call site that
// can only ever resolve into `JSX.IntrinsicElements` would be noise.
// project declaration can bind: a call site that can only ever resolve
// into `JSX.IntrinsicElements` would be noise, so it is its own kind,
// which is never a call (see `emit`) and carries no tag child. The row is
// what a plain tag's attributes -- `onClick={h}`, `ref={r}` -- belong to.
if (jsxTagReference(node) === undefined) {
return undefined;
return TsExpressionKind.JSX_INTRINSIC_ELEMENT;
}
return ts.isJsxElement(node) ? TsExpressionKind.JSX_ELEMENT : TsExpressionKind.JSX_SELF_CLOSING;
}
Expand Down Expand Up @@ -909,6 +916,16 @@ export function jsxTagReference(node: ts.Node): ts.Expression | undefined {
return undefined;
}

/** A JSX element's tag as written: `div`, `svg:circle`, `my-el`, `ui.Card`. */
export function jsxTagText(node: ts.Node, sf: ts.SourceFile): string {
const tagName = ts.isJsxElement(node)
? node.openingElement.tagName
: ts.isJsxSelfClosingElement(node)
? node.tagName
: undefined;
return tagName === undefined ? '' : tagName.getText(sf);
}

/** The receiver of a callee, or `undefined` for an unqualified call. */
export function receiverNodeOf(callee: ts.Node | undefined): ts.Node | undefined {
if (!callee) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as ts from 'typescript';

import {
ExpressionOwner,
jsxTagReference,
TsExpressionExtractor,
} from '@/parsers/typescript/extractors/ts-expression-extractor';
import { TsExpressionOwnerKind, TsRootContext } from '@/enums/typescript/expressions';
Expand Down Expand Up @@ -513,11 +512,12 @@ export class TsExpressionWalker {
// attribute values, children, and spreads at once, wherever the JSX sits.
//
// A component ELEMENT is a call in its own right: `<Badge/>` calls Badge.
// One that is already a row -- `cond ? <A/> : <B/>`, `x && <A/>` -- was
// reached by the worklist as an operand; any other is rooted here, which
// is what reaches an element nested in another element's children.
// An intrinsic one (`<div>`) is a JSX_INTRINSIC_ELEMENT row and no call,
// the element its attributes belong to. One that is already a row --
// `cond ? <A/> : <B/>`, `x && <div/>` -- was reached by the worklist as an
// operand; any other is rooted here, which is what reaches an element
// nested in another element's children.
if ((ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child))
&& jsxTagReference(child) !== undefined
&& !this.options.extractor.rowByNode.has(nodeId(child, this.sf))) {
this.root(child, TsRootContext.JSX_EMBEDDED_EXPRESSION);
return;
Expand Down
1 change: 1 addition & 0 deletions parser/src/schema/typescript/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,7 @@
"FUNCTION_EXPRESSION",
"IDENTIFIER_REFERENCE",
"JSX_ELEMENT",
"JSX_INTRINSIC_ELEMENT",
"JSX_SELF_CLOSING",
"LITERAL",
"NEW_EXPRESSION",
Expand Down
84 changes: 81 additions & 3 deletions parser/src/test/typescript-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as os from 'os';
import * as ts from 'typescript';
import * as path from 'path';

import { jsxTagReference } from '@/parsers/typescript/extractors/ts-expression-extractor';
import { IrCompletenessReport } from '@/parsers/typescript/extractors/ts-ir-completeness';
import { verifyRelationFileStreaming } from '@/workflows/typescript/ts-relation-writer';
import { TypeScriptProjectAnalyzer } from '@/workflows/typescript/typescript-project-analyzer';
Expand Down Expand Up @@ -1414,7 +1415,9 @@ function irCompleteness(): number {
* The component element is a call too: `<Badge/>` is `Badge({...})`, a
* JSX_COMPONENT_CALL site, wherever it sits -- a child, a ternary branch, the
* body of a `.map` arrow. The CONTROL is the intrinsic element: `<div>` and
* `<span>` name no declaration and must produce no call site at all.
* `<span>` name no declaration and must produce no call site at all. It is
* still an element, a childless JSX_INTRINSIC_ELEMENT row, so that
* `<button onClick={save}>` has an element for its handler to belong to.
*/
async function jsxBraceExpressionsWalked(): Promise<number> {
if (!parserPresent()) {
Expand All @@ -1430,6 +1433,8 @@ async function jsxBraceExpressionsWalked(): Promise<number> {
'declare function rows(): string[];',
'declare const props: Record<string, unknown>;',
'declare class Box { constructor(v: string); }',
'declare function setRef(e: HTMLButtonElement | null): void;',
'declare global { namespace JSX { interface IntrinsicElements { [tag: string]: any } } }',
'function Badge(p: { label: string }) { return <span>{p.label}</span>; }',
'export function Panel(msg: string, flag: boolean) {',
' return (',
Expand All @@ -1439,6 +1444,10 @@ async function jsxBraceExpressionsWalked(): Promise<number> {
' {flag ? <Badge label={fmt(msg)} /> : <span>{t("none")}</span>}',
' {rows().map((r) => <Badge key={r} label={fmt(r)} />)}',
' {new Box(t(msg)).toString()}',
' <button type="button" onClick={save} ref={setRef}>{t("go")}</button>',
' {flag && <input onChange={save} />}',
' <my-el data-x={fmt(msg)} />',
' <svg:circle r="1" />',
' </div>',
' );',
'}',
Expand Down Expand Up @@ -1493,6 +1502,15 @@ async function jsxBraceExpressionsWalked(): Promise<number> {
}
}
let expected = 0;
// Expression rows by position, so an element's row can be asserted.
const exprRows = relation(outputDir, 'all-typescript-expressions.csv')
.filter((r) => (modulePath.get(r.tsModuleLinkHash ?? '') ?? '').endsWith('panel.tsx'));
const exprKindsAt = new Map<string, string[]>();
for (const r of exprRows) {
const key = `panel.tsx:${r.startLine}:${r.startColumn}`;
exprKindsAt.set(key, [...(exprKindsAt.get(key) ?? []), r.kind ?? '']);
}
const parents = new Set(exprRows.map((r) => r.parentExpressionHash ?? ''));
let components = 0;
let intrinsics = 0;
const walk = (node: ts.Node): void => {
Expand All @@ -1507,12 +1525,43 @@ async function jsxBraceExpressionsWalked(): Promise<number> {
failures.push(`no JSX_COMPONENT_CALL at ${key} for \`${node.getText(sf).slice(0, 44)}\``
+ ` (found ${kindAt.get(key) ?? 'nothing'})`);
}
// The control: a component keeps its call-shaped row, never the intrinsic kind.
const want = ts.isJsxElement(node) ? 'JSX_ELEMENT' : 'JSX_SELF_CLOSING';
const kinds = exprKindsAt.get(key) ?? [];
if (!kinds.includes(want) || kinds.includes('JSX_INTRINSIC_ELEMENT')) {
failures.push(`component at ${key} has expression kind(s) [${kinds.join(', ')}], `
+ `expected ${want}`);
}
} else {
intrinsics += 1;
if (emitted.has(key)) {
failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} became a call site `
+ `(${kindAt.get(key)}) -- it names no declaration`);
}
// ...but it IS an element: one JSX_INTRINSIC_ELEMENT row, with no child
// (no tag reference for anything to resolve), so its attributes have an
// element to belong to, as in the JavaScript parser.
const rows = exprRows.filter((r) => `panel.tsx:${r.startLine}:${r.startColumn}` === key
&& r.kind === 'JSX_INTRINSIC_ELEMENT');
if (rows.length !== 1) {
failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} has ${rows.length} `
+ 'JSX_INTRINSIC_ELEMENT row(s), expected 1');
} else if (parents.has(rows[0]!.tsExpressionUniqueHash ?? '')) {
failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} has a child row -- `
+ 'its tag references nothing, so nothing hangs off it');
}
// An attribute's value is still its own rooted tree.
const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : node.attributes;
for (const attribute of attributes.properties) {
if (ts.isJsxAttribute(attribute) && attribute.initializer
&& ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression) {
const v = sf.getLineAndCharacterOfPosition(attribute.initializer.expression.getStart(sf));
const vkey = `panel.tsx:${v.line + 1}:${v.character + 1}`;
if ((exprKindsAt.get(vkey) ?? []).length === 0) {
failures.push(`attribute \`${attribute.getText(sf)}\` at ${vkey} has no value row`);
}
}
}
}
}
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
Expand All @@ -1528,6 +1577,33 @@ async function jsxBraceExpressionsWalked(): Promise<number> {
};
ts.forEachChild(sf, walk);

// The tag of a plain element is on its row, as written (`div`, `my-el`, `svg:circle`). The CONTROL is
// the component element, whose row keeps an empty potentialQualifiedName: its tag is the METHOD_NAME
// child that resolves, unchanged. The expectation is read off the AST.
let tagged = 0;
const walkTags = (node: ts.Node): void => {
if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) {
const opening = ts.isJsxElement(node) ? node.openingElement : node;
const p = sf.getLineAndCharacterOfPosition(node.getStart(sf));
const at = `${p.line + 1}:${p.character + 1}`;
const row = exprRows.find((r) => `${r.startLine}:${r.startColumn}` === at && (r.kind ?? '').startsWith('JSX_'));
const tag = opening.tagName.getText(sf);
const want = jsxTagReference(node) === undefined ? tag : '';
if (row === undefined) {
failures.push(`no element row at ${at} for <${tag}>`);
} else if ((row.potentialQualifiedName ?? '') !== want) {
failures.push(`<${tag}> at ${at}: potentialQualifiedName "${row.potentialQualifiedName}", expected "${want}"`);
} else if (want !== '') {
tagged += 1;
}
}
ts.forEachChild(node, walkTags);
};
ts.forEachChild(sf, walkTags);
if (tagged !== intrinsics) {
failures.push(`${tagged} of ${intrinsics} intrinsic element(s) carry their tag`);
}

// The edge roles that stay reserved: attributes and children are rooted trees.
for (const [file, names] of [
['all-typescript-call-sites.csv', ['callKind']],
Expand All @@ -1545,7 +1621,9 @@ async function jsxBraceExpressionsWalked(): Promise<number> {

console.log(` ${expected} call/new node(s) in JSX attributes, children, spreads and a ` +
`nested map; ${expected - missingCalls} emitted; ${components} component element(s) as ` +
`calls, ${intrinsics} intrinsic element(s) as none; ${RESERVED_TSX_VALUES.size} reserved ` +
`calls, ${intrinsics} intrinsic element(s) as JSX_INTRINSIC_ELEMENT rows and no call ` +
`(${tagged} with their tag); ` +
`${RESERVED_TSX_VALUES.size} reserved ` +
'value(s) still empty');
for (const f of failures.slice(0, 10)) console.log(` ${f}`);
fs.rmSync(root, { recursive: true, force: true });
Expand Down Expand Up @@ -4721,7 +4799,7 @@ const CHECKS: Check[] = [
{ name: 'tsc-adjudicated resolution', proves: 'every resolved call target equals getResolvedSignature', run: tscAdjudicatedResolution },
{ name: 'type-only isolation', proves: 'no type-only construct reaches the call graph', run: typeOnlyIsolation },
{ name: 'TSX reserved but empty', proves: 'the JSX attribute and child edge roles carry no rows', run: tsxReservedButEmpty },
{ name: 'JSX brace expressions are walked', proves: 'a call inside a JSX brace is an ordinary call site, a component element is a JSX_COMPONENT_CALL, an intrinsic element is none', run: jsxBraceExpressionsWalked },
{ name: 'JSX brace expressions are walked', proves: 'a call inside a JSX brace is an ordinary call site, a component element is a JSX_COMPONENT_CALL, an intrinsic element is a childless JSX_INTRINSIC_ELEMENT row carrying its tag and no call', run: jsxBraceExpressionsWalked },
{ name: 'destructuring records its source', proves: 'a bound name carries the property or index it binds, so a renamed or positional binding is recoverable', run: destructuringRecordsItsSource },
{ name: 'no emitted value can split a row', proves: 'no value contains a character a consumer treats as a line break, so a row cannot tear', run: noValueCanSplitARow },
{ name: 'signatures link their return type', proves: 'a call through any callable shape reaches a result type, so a chain does not stop at it', run: signaturesLinkTheirReturnType },
Expand Down
Loading