mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
The jsx-render pass read tag names off each parent's raw lines, so text
that only mentions a tag rendered it: react-router's hooks rendered the
`Router`, `Routes` and `Route` their JSDoc, comments and invariant
messages name ("You cannot render a <Router> inside another <Router>"),
shadcn's `useCarousel` the `Carousel` its error names, and layouts the
markup commented out of them (`{/* <Progress /> */}`).
The pass now scans a code-only view of each file (`jsxCode`, new in
src/resolution/jsx-code.ts): comments and the contents of string,
template and regex literals and of JSX attribute strings are blanked,
offsets and line breaks kept. The lexer follows JSX elements, so a quote,
slash or backtick in JSX text (`Don't`, `https://…`) stays text, and it
re-reads a `<` that opens a type parameter list (`<T,>(x: T) =>`) as an
operator. A file it can't read as one program (an unbalanced brace, a
string open at a line's end, a mismatched closing tag) is scanned as it
is, as before. TypeScript `.ts` files are skipped outright: they can't
hold JSX. The lexer's state lives in a class whose methods every file
shares: closures made per file sent V8 back to recompile it for file
after file.
Checked against TypeScript's parser on 36 React repos (7,649 files,
62,377 `<Name` matches): no real tag blanked, no file given up on, 614
non-JSX matches removed. Graph A/B on 12 repos: -36 jsx-render edges,
+0, nothing else changed; the TypeScript oracle grades all 36 as tags in
comments or strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
520 lines
16 KiB
TypeScript
520 lines
16 KiB
TypeScript
/**
|
|
* A JavaScript or JSX file's code, for a scan that reads JSX tags off it with a
|
|
* pattern.
|
|
*
|
|
* Text that only mentions a tag renders nothing: a JSDoc example, markup
|
|
* commented out (`{/* <Progress /> *\/}`), an error message ("You cannot
|
|
* render a <Router> inside another <Router>"), an attribute's value. So
|
|
* comments, and the contents of string, template and regex literals and of
|
|
* JSX attribute strings, are blanked. Every character keeps its offset and
|
|
* every line break its place, so a match in the result sits at the same line
|
|
* and column as in the source.
|
|
*
|
|
* Inside an element, a quote, a slash or a backtick is text: `<p>Don't have an
|
|
* account? <Link to="/signup">`, `<a>https://…</a>`. Read as code, that
|
|
* apostrophe opens a string that blanks the `<Link`. So the lexer follows
|
|
* elements the way JSX does: one opens where an expression can start, and its
|
|
* text runs to a tag or a `{`. A `<` that opens a type parameter list in the
|
|
* same place (`<T,>(x: T) =>`, `type Fn = <T>(x: T) => T`) reads as an element
|
|
* only until it fails to: the lexer then reads it again as an operator.
|
|
*
|
|
* Null when the file does not read as one program that way: a string still
|
|
* open at the end of its line, a comment, template or element still open at
|
|
* the end of the file, braces that don't balance, a closing tag that names
|
|
* another element, or a `</` or `/>` among code. A scan should then read the
|
|
* source as it is.
|
|
*/
|
|
|
|
/**
|
|
* What the lexer reads: code, a template's text, an element's opening tag (its
|
|
* attributes), or its children (JSX text, tags and `{…}`).
|
|
*/
|
|
type Mode = 'code' | 'template' | 'tag' | 'children';
|
|
|
|
interface Frame {
|
|
mode: Mode;
|
|
/** Code: braces opened in this frame and not closed yet. */
|
|
braces: number;
|
|
/** Tag and Children: the element's name, which its closing tag repeats. */
|
|
name: string;
|
|
}
|
|
|
|
/** Where an element opened among code: what to undo to read its `<` again. */
|
|
interface Root {
|
|
at: number;
|
|
depth: number;
|
|
blanks: number;
|
|
}
|
|
|
|
/** Keywords an expression follows: a `<` after one opens an element, a `/` a regex. */
|
|
const BEFORE_EXPRESSION = new Set([
|
|
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw',
|
|
'case', 'do', 'else', 'yield', 'await', 'default',
|
|
]);
|
|
|
|
/** How many elements may fail to read before the file is read as it is. */
|
|
const MAX_REREADS = 64;
|
|
|
|
const isSpace = (c: number): boolean =>
|
|
c === 32 || c === 10 || c === 13 || c === 9 || c === 11 || c === 12 || c === 0xa0 || c === 0xfeff;
|
|
const isIdStart = (c: number): boolean =>
|
|
(c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 36 || c === 95 || (c > 127 && !isSpace(c));
|
|
const isIdPart = (c: number): boolean => isIdStart(c) || (c >= 48 && c <= 57);
|
|
/** A JSX name's characters: an identifier's, and the `-`, `.` and `:` between its parts. */
|
|
const isNamePart = (c: number): boolean => isIdPart(c) || c === 45 || c === 46 || c === 58;
|
|
|
|
function skipSpace(src: string, k: number): number {
|
|
while (k < src.length && isSpace(src.charCodeAt(k))) k++;
|
|
return k;
|
|
}
|
|
|
|
function nameEnd(src: string, k: number): number {
|
|
while (k < src.length && isNamePart(src.charCodeAt(k))) k++;
|
|
return k;
|
|
}
|
|
|
|
function lineEnd(src: string, k: number): number {
|
|
for (let c = src.charCodeAt(k); k < src.length && c !== 10 && c !== 13; c = src.charCodeAt(++k));
|
|
return k;
|
|
}
|
|
|
|
/** The `/` closing the regex literal opened at `start`, or -1 when it doesn't close on its line. */
|
|
function regexEnd(src: string, start: number): number {
|
|
let inClass = false;
|
|
for (let k = start + 1; k < src.length; k++) {
|
|
const c = src.charCodeAt(k);
|
|
if (c === 10 || c === 13) return -1;
|
|
if (c === 92) {
|
|
k++;
|
|
if (src.charCodeAt(k) === 10 || src.charCodeAt(k) === 13) return -1;
|
|
} else if (c === 91) inClass = true;
|
|
else if (c === 93) inClass = false;
|
|
else if (c === 47 && !inClass) return k;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/** The quote closing the string opened at `start`, or -1 when the string doesn't close. */
|
|
function stringEnd(src: string, start: number): number {
|
|
const quote = src.charCodeAt(start);
|
|
for (let k = start + 1; k < src.length; k++) {
|
|
const c = src.charCodeAt(k);
|
|
if (c === quote) return k;
|
|
if (c === 92) k += src.charCodeAt(k + 1) === 13 && src.charCodeAt(k + 2) === 10 ? 2 : 1;
|
|
else if (c === 10 || c === 13) return -1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/** `src` with the ranges (start and end pairs, in order) turned to spaces, line breaks kept. */
|
|
function blankRanges(src: string, blanks: number[]): string {
|
|
if (blanks.length === 0) return src;
|
|
const parts: string[] = [];
|
|
let from = 0;
|
|
for (let b = 0; b < blanks.length; b += 2) {
|
|
const start = blanks[b]!;
|
|
const end = blanks[b + 1]!;
|
|
if (start < from) continue;
|
|
parts.push(src.slice(from, start));
|
|
const text = src.slice(start, end);
|
|
parts.push(/[\r\n]/.test(text) ? text.replace(/[^\r\n]/g, ' ') : ' '.repeat(end - start));
|
|
from = end;
|
|
}
|
|
parts.push(src.slice(from));
|
|
return parts.join('');
|
|
}
|
|
|
|
export function jsxCode(src: string): string | null {
|
|
return new JsxLexer(src).read();
|
|
}
|
|
|
|
/**
|
|
* The lexer's state for one file. Its steps are methods, not closures made
|
|
* per file: a closure made anew for each file is another call target to the
|
|
* optimizer, which threw `jsxCode`'s code away and compiled it again for
|
|
* file after file, at 40 to 90 ms a time (excalidraw's index did it 15 times).
|
|
*/
|
|
class JsxLexer {
|
|
private readonly n: number;
|
|
/** Ranges to blank, as start and end pairs, in source order. */
|
|
private readonly blanks: number[] = [];
|
|
private readonly stack: Frame[] = [{ mode: 'code', braces: 0, name: '' }];
|
|
/** The elements opened among code that are still open, innermost last. */
|
|
private readonly roots: Root[] = [];
|
|
/** `<`s that failed to read as an element. */
|
|
private readonly operators = new Set<number>();
|
|
private i = 0;
|
|
/** Whether the last token ends an expression: a `<` then compares, a `/` divides. */
|
|
private exprEnd = false;
|
|
/** Whether the last token is a `.`, so a keyword is a property's name. */
|
|
private afterDot = false;
|
|
private rereads = 0;
|
|
|
|
constructor(private readonly src: string) {
|
|
this.n = src.length;
|
|
}
|
|
|
|
read(): string | null {
|
|
const budget = 4 * this.n + 65536;
|
|
let steps = 0;
|
|
for (;;) {
|
|
while (this.i < this.n) {
|
|
if (++steps > budget) return null;
|
|
const top = this.stack[this.stack.length - 1]!;
|
|
const c = this.src.charCodeAt(this.i);
|
|
const ok =
|
|
top.mode === 'code' ? this.code(top, c)
|
|
: top.mode === 'template' ? this.template(c)
|
|
: top.mode === 'tag' ? this.tag(top, c)
|
|
: this.children(top, c);
|
|
if (!ok) return null;
|
|
}
|
|
if (this.stack.length === 1 && this.stack[0]!.braces === 0) break;
|
|
if (!this.reread()) return null;
|
|
}
|
|
return blankRanges(this.src, this.blanks);
|
|
}
|
|
|
|
private blank(from: number, to: number): void {
|
|
if (to > from) this.blanks.push(from, Math.min(to, this.n));
|
|
}
|
|
|
|
private codeFrame(): void {
|
|
this.stack.push({ mode: 'code', braces: 0, name: '' });
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
}
|
|
|
|
/**
|
|
* Undo back to the innermost element still open among code and read its
|
|
* `<` again as an operator. False when there is none, or too many have
|
|
* failed: the file is then read as it is.
|
|
*/
|
|
private reread(): boolean {
|
|
const root = this.roots.pop();
|
|
if (!root || ++this.rereads > MAX_REREADS) return false;
|
|
this.stack.length = root.depth;
|
|
this.blanks.length = root.blanks;
|
|
for (const k of this.operators) if (k > root.at) this.operators.delete(k);
|
|
this.operators.add(root.at);
|
|
this.i = root.at;
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
|
|
/** Open the element whose `<` is at `lt`. False when it cannot be one. */
|
|
private openElement(lt: number): boolean {
|
|
const src = this.src;
|
|
if (src.charCodeAt(lt + 1) === 62) {
|
|
this.stack.push({ mode: 'children', braces: 0, name: '' });
|
|
this.i = lt + 2;
|
|
return true;
|
|
}
|
|
let k = nameEnd(src, lt + 1);
|
|
const name = src.slice(lt + 1, k);
|
|
// `<Entry extends BaseEntity>(…) =>`: a generic arrow function's type parameters.
|
|
const next = skipSpace(src, k);
|
|
if (next > k && src.startsWith('extends', next) && isSpace(src.charCodeAt(next + 7))) return false;
|
|
if (src.charCodeAt(k) === 60) {
|
|
// `<Select<Option> …>`: the element's type arguments.
|
|
let depth = 0;
|
|
const limit = Math.min(this.n, k + 1000);
|
|
for (; k < limit; k++) {
|
|
const c = src.charCodeAt(k);
|
|
if (c === 60) depth++;
|
|
else if (c === 62 && --depth === 0) break;
|
|
}
|
|
if (depth !== 0) return false;
|
|
k++;
|
|
}
|
|
this.stack.push({ mode: 'tag', braces: 0, name });
|
|
this.i = k;
|
|
return true;
|
|
}
|
|
|
|
/** Close the innermost element. Closing an element opened among code ends an expression. */
|
|
private closeElement(): void {
|
|
this.stack.pop();
|
|
if (this.stack[this.stack.length - 1]!.mode === 'code') {
|
|
this.roots.pop();
|
|
this.exprEnd = true;
|
|
this.afterDot = false;
|
|
}
|
|
}
|
|
|
|
/** One step among code. False when the file is given up. */
|
|
private code(top: Frame, c: number): boolean {
|
|
const src = this.src;
|
|
const i = this.i;
|
|
if (isSpace(c)) {
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (c === 47) {
|
|
const d = src.charCodeAt(i + 1);
|
|
if (d === 47) {
|
|
const end = lineEnd(src, i);
|
|
this.blank(i, end);
|
|
this.i = end;
|
|
return true;
|
|
}
|
|
if (d === 42) {
|
|
const end = src.indexOf('*/', i + 2);
|
|
if (end === -1) return this.reread();
|
|
this.blank(i, end + 2);
|
|
this.i = end + 2;
|
|
return true;
|
|
}
|
|
if (this.exprEnd) {
|
|
// `/>` among code: an element read as code.
|
|
if (d === 62) return this.reread();
|
|
this.i = i + 1;
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
const end = regexEnd(src, i);
|
|
if (end === -1) {
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
this.blank(i + 1, end);
|
|
let k = end + 1;
|
|
while (k < this.n && isIdPart(src.charCodeAt(k))) k++;
|
|
this.i = k;
|
|
this.exprEnd = true;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (c === 34 || c === 39) {
|
|
const end = stringEnd(src, i);
|
|
if (end === -1) return this.reread();
|
|
this.blank(i + 1, end);
|
|
this.i = end + 1;
|
|
this.exprEnd = true;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (c === 96) {
|
|
this.stack.push({ mode: 'template', braces: 0, name: '' });
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (c === 60) {
|
|
const d = src.charCodeAt(i + 1);
|
|
if (!this.exprEnd && (d === 62 || isIdStart(d)) && !this.operators.has(i)) {
|
|
this.roots.push({ at: i, depth: this.stack.length, blanks: this.blanks.length });
|
|
return this.openElement(i) || this.reread();
|
|
}
|
|
// `</` among code: a closing tag read as code.
|
|
if (d === 47) return this.reread();
|
|
this.i = i + (d === 60 || d === 61 ? 2 : 1);
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (c === 123) {
|
|
top.braces++;
|
|
this.i = i + 1;
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (c === 125) {
|
|
if (top.braces > 0) {
|
|
top.braces--;
|
|
this.i = i + 1;
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (this.stack.length === 1) return this.reread();
|
|
// The end of a JSX `{…}` or of a template's `${…}`.
|
|
this.stack.pop();
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (isIdStart(c)) {
|
|
let end = i + 1;
|
|
while (end < this.n && isIdPart(src.charCodeAt(end))) end++;
|
|
this.exprEnd = this.afterDot || !BEFORE_EXPRESSION.has(src.slice(i, end));
|
|
this.afterDot = false;
|
|
this.i = end;
|
|
return true;
|
|
}
|
|
const d = src.charCodeAt(i + 1);
|
|
if ((c >= 48 && c <= 57) || (c === 46 && d >= 48 && d <= 57)) {
|
|
let end = i + 1;
|
|
for (let e = src.charCodeAt(end); end < this.n && (isIdPart(e) || e === 46); e = src.charCodeAt(++end));
|
|
this.i = end;
|
|
this.exprEnd = true;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (c === 46) {
|
|
if (d === 46 && src.charCodeAt(i + 2) === 46) {
|
|
this.i = i + 3;
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
} else {
|
|
this.i = i + 1;
|
|
this.exprEnd = false;
|
|
this.afterDot = true;
|
|
}
|
|
return true;
|
|
}
|
|
if (c === 41 || c === 93) {
|
|
this.i = i + 1;
|
|
this.exprEnd = true;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
// `++` and `--` leave the expression where it was, and so does TypeScript's `x!`.
|
|
if ((c === 43 || c === 45) && d === c) {
|
|
this.i = i + 2;
|
|
return true;
|
|
}
|
|
if (c === 33 && this.exprEnd && d !== 61) {
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (c === 35 && i === 0 && d === 33) {
|
|
this.i = lineEnd(src, 0);
|
|
return true;
|
|
}
|
|
this.i = i + 1;
|
|
this.exprEnd = false;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
|
|
/** One step in a template's text. */
|
|
private template(c: number): boolean {
|
|
const src = this.src;
|
|
const i = this.i;
|
|
if (c === 96) {
|
|
this.stack.pop();
|
|
this.i = i + 1;
|
|
this.exprEnd = true;
|
|
this.afterDot = false;
|
|
return true;
|
|
}
|
|
if (c === 92) {
|
|
this.blank(i, i + 2);
|
|
this.i = i + 2;
|
|
return true;
|
|
}
|
|
if (c === 36 && src.charCodeAt(i + 1) === 123) {
|
|
this.codeFrame();
|
|
this.i = i + 2;
|
|
return true;
|
|
}
|
|
let end = i + 1;
|
|
for (let e = src.charCodeAt(end); end < this.n && e !== 96 && e !== 92 && e !== 36; e = src.charCodeAt(++end));
|
|
this.blank(i, end);
|
|
this.i = end;
|
|
return true;
|
|
}
|
|
|
|
/** One step in an element's opening tag. False when the file is given up. */
|
|
private tag(top: Frame, c: number): boolean {
|
|
const src = this.src;
|
|
const i = this.i;
|
|
if (isSpace(c)) {
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (c === 62) {
|
|
top.mode = 'children';
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (c === 47) {
|
|
const d = src.charCodeAt(i + 1);
|
|
if (d === 62) {
|
|
this.i = i + 2;
|
|
this.closeElement();
|
|
return true;
|
|
}
|
|
if (d === 47) {
|
|
const end = lineEnd(src, i);
|
|
this.blank(i, end);
|
|
this.i = end;
|
|
return true;
|
|
}
|
|
if (d === 42) {
|
|
const end = src.indexOf('*/', i + 2);
|
|
if (end !== -1) {
|
|
this.blank(i, end + 2);
|
|
this.i = end + 2;
|
|
return true;
|
|
}
|
|
}
|
|
return this.reread();
|
|
}
|
|
if (c === 123) {
|
|
this.codeFrame();
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (isIdStart(c)) {
|
|
const end = nameEnd(src, i + 1);
|
|
const eq = skipSpace(src, end);
|
|
if (src.charCodeAt(eq) !== 61) {
|
|
this.i = end;
|
|
return true;
|
|
}
|
|
const value = skipSpace(src, eq + 1);
|
|
const q = src.charCodeAt(value);
|
|
if (q === 34 || q === 39) {
|
|
// JSX strings have no escapes and may span lines.
|
|
const close = src.indexOf(q === 34 ? '"' : "'", value + 1);
|
|
if (close === -1) return this.reread();
|
|
this.blank(value + 1, close);
|
|
this.i = close + 1;
|
|
return true;
|
|
}
|
|
if (q === 123) {
|
|
this.codeFrame();
|
|
this.i = value + 1;
|
|
return true;
|
|
}
|
|
const next = src.charCodeAt(value + 1);
|
|
if (q === 60 && (next === 62 || isIdStart(next)) && this.openElement(value)) return true;
|
|
}
|
|
return this.reread();
|
|
}
|
|
|
|
/** One step among an element's children. False when the file is given up. */
|
|
private children(top: Frame, c: number): boolean {
|
|
const src = this.src;
|
|
const i = this.i;
|
|
if (c === 123) {
|
|
this.codeFrame();
|
|
this.i = i + 1;
|
|
return true;
|
|
}
|
|
if (c === 60) {
|
|
const d = src.charCodeAt(i + 1);
|
|
if (d === 47) {
|
|
const start = skipSpace(src, i + 2);
|
|
const end = nameEnd(src, start);
|
|
const gt = skipSpace(src, end);
|
|
if (src.charCodeAt(gt) !== 62 || src.slice(start, end) !== top.name) return this.reread();
|
|
this.i = gt + 1;
|
|
this.closeElement();
|
|
return true;
|
|
}
|
|
if ((d === 62 || isIdStart(d)) && this.openElement(i)) return true;
|
|
return this.reread();
|
|
}
|
|
// JSX text can't hold a `>` or a `}`: code read as text.
|
|
if (c === 62 || c === 125) return this.reread();
|
|
let end = i + 1;
|
|
for (let e = src.charCodeAt(end); end < this.n && e !== 123 && e !== 60 && e !== 62 && e !== 125; e = src.charCodeAt(++end));
|
|
this.i = end;
|
|
return true;
|
|
}
|
|
}
|