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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
- In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* <Progress /> */}`, or an error message like react-router's "You cannot render a <Router> inside another <Router>" or shadcn's "useCarousel must be used within a <Carousel />". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `<p>Don't have an account? <Link to="/signup">`, still links as before. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading.
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
Expand Down
230 changes: 230 additions & 0 deletions __tests__/jsx-child-disambiguation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
import { jsxCode } from '../src/resolution/jsx-code';

/**
* A JSX tag names ONE component, and the file it is written in says which:
Expand Down Expand Up @@ -1002,3 +1003,232 @@ export default React.forwardRef<HTMLButtonElement, object>(Button);
expect(renders('Deck')).toEqual(['Card components/Card.js:3']);
});
});

/**
* The pass reads tag names off a parent's source with a pattern, so it also
* read the tags that text only mentions. react-router's hooks rendered the
* `Router`, `Routes` and `Route` their invariant messages and comments name
* ("You cannot render a <Router> inside another <Router>"), shadcn's
* `useCarousel` the `Carousel` its error names, and a layout the `<Progress />`
* commented out of it. A tag in a comment, or in a string, template, regex or
* attribute value, is text; so is anything in a TypeScript `.ts` file, which
* holds no JSX at all.
*/
describe('JSX child: a tag written in a comment or a string', () => {
let dir: string;
let cg: any;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-text-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
});

afterEach(() => {
cg?.close?.();
fs.rmSync(dir, { recursive: true, force: true });
});

const write = (rel: string, lines: string[]) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, lines.join('\n') + '\n');
};
const component = (name: string, tag = 'div') => [`export function ${name}() {`, ` return <${tag} />;`, '}'];

async function index(): Promise<void> {
cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
}

/** The names a jsx-render edge out of `parent` points at. */
const renders = (parent: string): string[] =>
cg.db.db
.prepare(
`SELECT t.name AS r FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
ORDER BY r`
)
.all(parent)
.map((row: any) => row.r);

it('renders nothing for a tag in a comment, a string, a template or a regex', async () => {
write('router/components.tsx', ['Router', 'Routes', 'Route', 'Outlet', 'Blog'].flatMap((n) => component(n)));
write('router/hooks.tsx', [
"import { Outlet } from './components';",
'',
'export function useRoutesImpl(routes, parentPath) {',
' invariant(',
' useInRouterContext(),',
' `useRoutes() may be used only in the context of a <Router> component.`',
' );',
" // You won't get a warning about 2 different <Routes> under a <Route>:",
' //',
' // <Route path="blog" element={<Blog />} />',
' warning(',
' !parentPath,',
' `You rendered descendant <Routes> (under <Route path="${parentPath}">) ` +',
' "but the parent route path has no trailing \\"*\\"."',
' );',
' const selfClosing = /<Router \\/>/;',
' return <Outlet />;',
'}',
]);
// shadcn's carousel hook, and excalidraw's docs theme toggle.
write('components/ui/carousel.tsx', [
"import * as React from 'react';",
'const CarouselContext = React.createContext(null);',
'export function useCarousel() {',
' const context = React.useContext(CarouselContext);',
" if (!context) throw new Error('useCarousel must be used within a <Carousel />');",
' return context;',
'}',
'export function Carousel({ children }) {',
' return <CarouselContext.Provider value={{}}>{children}</CarouselContext.Provider>;',
'}',
]);
write('docs/Excalidraw.tsx', component('Excalidraw'));
write('docs/ToggleTheme.tsx', [
'export function ToggleTheme({ onThemeChange }) {',
' if (!onThemeChange) {',
' throw new Error("`<Excalidraw/> props.onThemeChange` must be defined when `theme` is set");',
' }',
' return <button onClick={onThemeChange} />;',
'}',
]);
// bulletproof-react's layout and binner's inventory: markup commented out.
write('components/Drawer.tsx', component('Drawer'));
write('components/Progress.tsx', component('Progress'));
write('components/Layout.jsx', [
"import { Drawer } from './Drawer';",
"import { Progress } from './Progress';",
'export function Layout({ children }) {',
' /*<Progress value={0} />*/',
' return (',
' <div>',
' {/* <Progress /> */}',
' <Drawer>{children}</Drawer>',
' </div>',
' );',
'}',
]);
await index();
expect(renders('useRoutesImpl')).toEqual(['Outlet']);
expect(renders('useCarousel')).toEqual([]);
expect(renders('ToggleTheme')).toEqual([]);
expect(renders('Layout')).toEqual(['Drawer']);
});

it('renders nothing from a TypeScript .ts file', async () => {
// outline's OpenSearch description, an XML template in a `.ts` route.
write('app/components/Image.tsx', component('Image', 'img'));
write('app/components/Description.tsx', component('Description', 'p'));
write('server/routes/opensearch.ts', [
'export function opensearchResponse(baseUrl: string): string {',
' return `<?xml version="1.0" encoding="UTF-8"?>',
'<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">',
' <Description>Search Outline</Description>',
' <Image width="16" height="16">${baseUrl}/images/favicon-16.png</Image>',
'</OpenSearchDescription>`;',
'}',
]);
// A type assertion names a class component. The regex right after
// `if (…)` reads as a division, so the file can't be followed and is read
// as it is: only its language says `<Editor>` is no tag.
write('app/editor/Editor.tsx', [
"import * as React from 'react';",
'export class Editor extends React.Component {',
' render() {',
' return <div />;',
' }',
'}',
]);
write('app/editor/focus.ts', [
"import { Editor } from './Editor';",
'',
'export function focusEditor(view: unknown, closing = "</p>") {',
" if (closing) /'/.test(closing);",
' const editor = <Editor>view;',
' return editor;',
'}',
]);
await index();
expect(renders('opensearchResponse')).toEqual([]);
expect(renders('focusEditor')).toEqual([]);
});

it('still renders the tags after an apostrophe, a URL or a backtick in JSX text', async () => {
for (const name of ['Link', 'Docs', 'Kbd', 'Field', 'Fake']) write(`app/${name}.tsx`, component(name));
write('app/Signup.tsx', [
"import { Link } from './Link';",
"import { Docs } from './Docs';",
"import { Kbd } from './Kbd';",
"import { Field } from './Field';",
'',
'const first = <T,>(items: T[]) => items[0];',
"const slug = (s: string) => s.replace(/[`'\"]/g, '').replace(/\\//g, '-');",
'const note = `${`nested ${first([1])}`} <Fake />`;',
'',
'export function Signup() {',
' return (',
' <form title="<Fake /> is not rendered">',
' <p>Don\'t have an account? <Link to="/signup">Sign up</Link></p>',
' <p>Read https://example.com/docs first: <Docs /></p>',
' <p>Press `Ctrl` and <Kbd>K</Kbd></p>',
' <Field label=\'It"s\' />',
' </form>',
' );',
'}',
]);
await index();
expect(renders('Signup')).toEqual(['Docs', 'Field', 'Kbd', 'Link']);
});
});

describe('jsxCode', () => {
const TAG = /<([A-Z][A-Za-z0-9_]*)(?=[\s/><])/g;
const tags = (code: string) => [...code.matchAll(TAG)].map((m) => m[1]);

it('blanks comments and literal text, and keeps every offset and line break', () => {
const source = [
'/** Renders a <Route> in an <Outlet>. */',
'export function Page({ items }) {',
" const re = /<Fake\\/>/g, s = 'a <Fake />', t = `<Fake /> ${items.map((i) => <Item key={i} />)}`;",
' return (',
' <Shell title="<Fake />">',
" {/* <Fake /> */} Don't <Link to=\"/x\">go</Link> to https://a.b/c",
' </Shell>',
' );',
'}',
].join('\r\n');
const code = jsxCode(source)!;
expect(code).not.toBeNull();
expect(code.length).toBe(source.length);
expect([...code].flatMap((c, i) => (c === '\n' || c === '\r' ? [i] : []))).toEqual(
[...source].flatMap((c, i) => (c === '\n' || c === '\r' ? [i] : []))
);
expect(tags(source)).toContain('Fake');
expect(tags(code)).toEqual(['Item', 'Shell', 'Link']);
});

it('reads a `<` that opens a type parameter list as an operator', () => {
const source = [
'type Fn = <T>(x: T) => T;',
'interface Call { <T>(x: T): T }',
'const first = <T,>(items: T[]) => items[0];',
"const Table = <Entry extends Base>({ rows }) => <Grid rows={rows} label='a \"b' />;",
'const c = i++ / 2, d = x! < y;',
].join('\n');
const code = jsxCode(source)!;
expect(code).not.toBeNull();
expect(tags(code)).toEqual(['T', 'T', 'Entry', 'Grid']);
});

it('is null for a file it cannot read as one program', () => {
expect(jsxCode('const a = <p>a } b</p>;')).toBeNull();
expect(jsxCode('function f() {\n return 1;\n')).toBeNull();
expect(jsxCode("const s = 'open\nconst b = 1;")).toBeNull();
expect(jsxCode('const a = <A><B></A></B>;')).toBeNull();
});
});
18 changes: 13 additions & 5 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher';
import { declaredComponent, exportedComponent, lazyValueModules } from './frameworks/react';
import { jsxCode } from './jsx-code';

const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
Expand All @@ -52,6 +53,8 @@ const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
const SETSTATE_RE = /this\.setState\s*\(/;
const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
/** The languages that can write JSX: not TypeScript's `.ts` files. */
const JSX_FAMILY = ['tsx', 'jsx', 'javascript'];
// A tag's name ends at whitespace, `/` or `>`, or at the `<` of the type
// arguments a generic component's tag passes: `<PaginatedList<Document>
// items={…} />` renders PaginatedList. The lookahead leaves that `<` to open
Expand Down Expand Up @@ -1930,7 +1933,9 @@ function declaredInside(ctx: ResolutionContext, name: string, parent: Node, chil
* so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
* JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
* (read each JSX file once). Precision gates: the parent must write the name as
* a tag, not only in a type argument or parameter list (`useState<User>()`,
* a tag in its code, not in a comment, a string or a template (react-router's
* "You cannot render a <Router> inside another <Router>"; `jsxCode`), and
* not only in a type argument or parameter list (`useState<User>()`,
* `<Entry extends BaseEntity>(…) =>`; outline's `<PaginatedList<Document>`
* read its `Document` model class as a child), and the name must resolve to a
* component/function/class node, or to a value the file declares itself (see
Expand Down Expand Up @@ -1966,9 +1971,11 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
const content = ctx.readFile(file);
if (!content || (!content.includes('</') && !content.includes('/>'))) continue; // JSX-file gate
// File-level language gate, not merely a project-level one: mixed C/JS
// monorepos must not interpret `"<Foo/>"` inside C as JSX (#1560).
// monorepos must not interpret `"<Foo/>"` inside C as JSX (#1560). A
// TypeScript `.ts` file holds no JSX either: a `<Name>` there is a type
// assertion or text, like outline's OpenSearch XML template.
const parents = ctx.getNodesInFile(file).filter(
(n) => PARENT_KINDS.has(n.kind) && JS_FAMILY.includes(n.language)
(n) => PARENT_KINDS.has(n.kind) && JSX_FAMILY.includes(n.language)
);
if (parents.length === 0) continue;
// Read once per file, and only when a name actually turns out ambiguous.
Expand All @@ -1985,7 +1992,8 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
// the same lines share them: every function of a minified bundle spans
// its one line, and reading that line again per function took 9 to 25 s
// over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The
// file is split once, not once per function.
// file is split once, not once per function, and read as code once: a
// tag in a comment or a string is text (`jsxCode`).
let lines: string[] | null = null;
// Each name a span holds, and the line of its first tag: 0 while only type
// argument or parameter lists name it (`opensTag`). `bare` holds the
Expand All @@ -1997,7 +2005,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
let names = tagsBySpan.get(span);
if (!names) {
names = { tags: new Map<string, number>(), bare: new Set<string>() };
lines ??= content.split('\n');
lines ??= (jsxCode(content) ?? content).split('\n');
const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n');
if (src.includes('</') || src.includes('/>')) {
let line = parent.startLine;
Expand Down
Loading