diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1101f26..64a62b086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((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 for a component a function destructures from its props or from an object, like `({ Header, Footer, children }) =>` in refine's layouts or `const { Sidebar, Footer } = excalidrawLib` in excalidraw's example app, now links to nothing, as a tag naming a variable the function sets itself already did, unless the function declares a component of that name inside itself. A function nested in the one that sets or destructures the name, like a `renderSidebar` helper inside the component, reads the tag the same way. A name destructured from `require(…)` or `import(…)` still links to that module's export. Before, these tags were matched by their name alone, so refine's layouts showed up among the callers of other examples' `Header` and `Footer` components, and `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. +- In React, a tag for a component a file destructures at its top, like `const { Title, Text } = Typography;` with antd's `Typography`, as in refine's pages, now links to nothing in every component of that file, unless the component declares one of that name inside itself. A name destructured from `require(…)` there still links to that module's export. Before, these tags were matched by their name alone, so refine's pages showed up among the callers of other examples' `Title` and `Text` components. 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 `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". 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 `

Don't have an account? `, 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. - In JavaScript and TypeScript, a function the file imports from a package and passes along as a value, like react-router's `Link` in `styled(Link)`, lodash's `noop` in `createContext({ onClose: noop })` or chart.js's `Legend` in `Chart.register(Legend)`, no longer links to a project function that only shares its name. Before, whenever the project had exactly one function of that name, every such value linked to it: outline's breadcrumbs, menus and document lists showed up among the callers of an email template's `Link`, and a test's own `noop` stub in cord-field among the code the app's contexts use, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A function the file declares itself, including one that hides the import inside a function, and one imported from a file of your project link as before. Re-index JavaScript and TypeScript projects after upgrading. diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts index 4791a7db7..244328a91 100644 --- a/__tests__/jsx-child-disambiguation.test.ts +++ b/__tests__/jsx-child-disambiguation.test.ts @@ -484,6 +484,62 @@ module.exports = { LegacyPage, renderChart }; expect(renders('LegacyPage')).toEqual(['Legacy app/legacy.tsx:1']); expect(renders('renderChart')).toEqual(['Chart app/chart.tsx:1']); }); + + it('renders nothing for a name the file destructures at its top, unless a function declares it inside itself', async () => { + write('examples/finefoods/title.tsx', 'export function Title() {\n return

;\n}\n'); + write('examples/crm/text.tsx', 'export function Text() {\n return ;\n}\n'); + // refine's pages take antd's typography parts once, at the top. + write( + 'examples/blog/show.tsx', + `import { Typography } from 'antd'; + +const { Title, Text } = Typography; + +export function PostShow({ post }) { + return ( +
+ Title + {post.title} +
+ ); +} + +export function Summary() { + function Text({ children }) { + return

{children}

; + } + return summary; +} +` + ); + await index(); + expect(renders('PostShow')).toEqual([]); + expect(renders('Summary')).toEqual(['Text examples/blog/show.tsx:15']); + }); + + it('still renders the component for a top-level require, a top-level callback’s parameter or another function’s local', async () => { + write('app/legacy.tsx', 'export function Legacy() {\n return
;\n}\n'); + write('app/badge.tsx', 'export function Badge() {\n return ;\n}\n'); + write('app/chart.tsx', 'export function Chart() {\n return ;\n}\n'); + write( + 'app/page.jsx', + `const { Legacy } = require('./legacy'); + +[1].forEach(({ Badge }) => Badge); + +function helper(props) { + const { Chart } = props; + return Chart; +} + +export function Page() { + return
{helper}
; +} +` + ); + await index(); + expect(renders('Page')).toEqual(['Badge app/badge.tsx:1', 'Chart app/chart.tsx:1', 'Legacy app/legacy.tsx:1']); + }); }); /** diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 49cc271b8..b0947d248 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -2023,6 +2023,47 @@ function destructures(src: string, name: string, end: number): boolean { return false; } +/** Where a destructuring declaration's pattern opens: `const {`, `let [`. */ +const DESTRUCTURING_DECLARATION = /\b(?:const|let|var)\s*[{[]/g; + +/** + * The patterns `code` destructures at its top, as [opener, closer] pairs: a + * declaration's outside every brace, so in no function, class or block, and + * never a top-level callback's parameter. Braces alone are counted: JSX text + * may hold a stray `(` or `[`, never a `{` that opens no expression, and + * `code` (`jsxCode`) balances them. + */ +function topLevelPatterns(code: string): Array<[number, number]> { + const patterns: Array<[number, number]> = []; + let depth = 0; + let at = 0; + DESTRUCTURING_DECLARATION.lastIndex = 0; + for (let m = DESTRUCTURING_DECLARATION.exec(code); m; m = DESTRUCTURING_DECLARATION.exec(code)) { + for (; at < m.index; at++) { + if (code[at] === '{') depth++; + else if (code[at] === '}') depth--; + } + if (depth !== 0) continue; + const open = m.index + m[0].length - 1; + const close = matchingCloser(code, open); + if (close > open) patterns.push([open, close]); + } + return patterns; +} + +/** + * Whether one of `patterns` in `code` binds `name` (`bindingElementAt`), so + * not in a declaration of `require(…)` or `import(…)`. + */ +function patternsBind(code: string, patterns: Array<[number, number]>, name: string): boolean { + for (const [open, close] of patterns) { + for (let at = code.indexOf(name, open); at !== -1 && at < close; at = code.indexOf(name, at + name.length)) { + if (bindingElementAt(code, at, name.length)) return true; + } + } + return false; +} + /** * Whether the name at `at` is an element of a pattern that binds it — `{ * Name }`, `{ ...Name }`, `{ key: Name }`, `[Name]` — followed by the next @@ -2165,9 +2206,11 @@ function skipSpace(code: string, at: number, step: 1 | -1): number { * `Layout({ children, Header, Footer, Sider })` rendered another example's * `Footer`, and excalidraw's example app rendered the `Sidebar` of its own * `ExampleSidebar.tsx` for the library's, out of `const { Sidebar, Footer } = - * excalidrawLib`. A destructured name keeps linking for a bare call — `const - * { t } = useI18n()` is that function more often than not — but a tag's is a - * component the props or a library hand in, which is no node here. + * excalidrawLib`. A name the file destructures at its top, like refine's + * `const { Title, Text } = Typography`, is a local of every function in it + * (`topLevelPatterns`). A destructured name keeps linking for a bare call — + * `const { t } = useI18n()` is that function more often than not — but a + * tag's is a component the props or a library hand in, which is no node here. */ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { let scannedFiles = 0; @@ -2217,6 +2260,8 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): // over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The // 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 code: string | null | undefined; + const codeOf = () => (code === undefined ? (code = jsxCode(content)) : code); let lines: string[] | null = null; // Each name a span holds (`spanTags`), read when a function among the // parents is, or a function around one. @@ -2225,12 +2270,27 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): const span = `${fn.startLine}:${fn.endLine}`; let names = tagsBySpan.get(span); if (!names) { - lines ??= (jsxCode(content) ?? content).split('\n'); + lines ??= (codeOf() ?? content).split('\n'); names = spanTags(lines.slice(fn.startLine - 1, fn.endLine).join('\n'), fn.startLine); tagsBySpan.set(span, names); } return names; }; + // Whether the file destructures a name at its top (`topLevelPatterns`), + // once per name. Read only off code: a file `jsxCode` can't read binds + // nothing here. + let topPatterns: Array<[number, number]> | null = null; + const topBinds = new Map(); + const destructuredAtTop = (name: string): boolean => { + let binds = topBinds.get(name); + if (binds === undefined) { + const src = codeOf(); + topPatterns ??= src === null ? [] : topLevelPatterns(src); + binds = src !== null && topPatterns.length > 0 && patternsBind(src, topPatterns, name); + topBinds.set(name, binds); + } + return binds; + }; for (const parent of parents) { if (!parent.startLine || !parent.endLine) continue; const names = spanOf(parent); @@ -2258,6 +2318,11 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): } } if (binder) child = declaredInside(ctx, name, binder, child); + // A name the file destructures at its top is every function's local: + // refine's pages take `const { Title, Text } = Typography` from antd + // and rendered two other examples' `Title` and `Text`. A function + // that declares the name inside itself still renders its own. + else if (destructuredAtTop(name)) child = declaredInside(ctx, name, around?.[around.length - 1] ?? parent, child); } if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue; const key = `${parent.id}>${child.id}`;