mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-23 06:50:54 +08:00
fix(js,ts): a receiver declared outside the calling function goes by its declared type (#2811)
react-dom's root.render(<X />) linked to the project's one class
component render() when root was declared outside the calling
function, a module's let root: Root | null or a let popupRoot: Root in
the function around the one rendering, or held what createRoot(el)
returned (#2589). The receiver reader scans only the calling function,
so the type that rules a react-dom Root out was never read, and the
call went to the one same-named method by its name.
A TS/JS receiver the calling function doesn't type, in a function
nested in another or one a module-scope variable names, is now read by
its binding in scope at the call. A per-file brace-scope reader finds
the innermost binding of the name: a const/let/var, a parameter, a
destructuring, a for head, a function or class, a lone or destructured
arrow parameter. A declaration or parameter written with a type, or a
const/let/var whose whole value is a new of a class, types the call as
the calling function's own declaration would; an annotated object
literal stays its own members', and a new of a parameter names no
class. A class component's render() and lifecycle methods are never a
by-name guess for a receiver other than this or super, since React
calls them, which also covers an untyped const r = createRoot(el). A
first version that also took any value a package function returned to
have no project method was dropped: it unlinked correct calls through
useContext(...) values in social-app and outline.
Validation: the new test fails 3 of 3 on main 89cd08fa and passes on
the fix. A/B against main on 14 TS/JS repos: 35 edges removed,
every one a wrong guess on a value whose type outside the calling
function comes from outside the project (rolldown's watcher, node's
Server and ChildProcess, playwright, unstorage, zod, NodeJS.WriteStream,
HTMLElement) or nitro's ReactDOM.createRoot root; 18 added and
7 retargeted, all precise (memfs's onAbort watcher.close() now
reaches FsaNodeFsWatcher::close, jest's currentSpec.cancel() Spec's);
92 same-target edges typed at 0.9 instead of guessed. The flask and
gin control dumps are byte-identical. Index time, median of 8
interleaved runs on vite, nuxt, webdriverio and insomnia: +1.7% total,
within noise (-3.6 to +5.2% per repo). A CLI sync that retypes a
module binding and adds files matches a fresh index.
Fixes #2589
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3fdba2955
commit
08478eea53
@@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
|
||||
- In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588)
|
||||
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
|
||||
- In JavaScript and TypeScript, react-dom's `root.render(<Popup />)` no longer links to your own class component's `render()`, such as an error boundary's, when `root` is declared outside the function making the call, like a module's `let root: Root | null`, or holds what `createRoot(el)` returned. A call on a variable declared outside the calling function now goes by the type that declaration gives it, so one declared with your own class, like `const store = new Store()` at the top of a file, links to that class's method, and a class component's `render()` and lifecycle methods, which only React calls, are no longer a guess for an untyped value. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report. (#2589)
|
||||
- In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600)
|
||||
- In Python, `from app import email` no longer links the importing file to a project variable named `app`, such as a FastAPI or Flask `app = FastAPI()`. (#2584)
|
||||
- In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* react-dom's `root.render(<X />)` linked to the project's one class-component
|
||||
* `render()` (#2589) when `root` was declared outside the calling function — a
|
||||
* module's `let root: Root | null`, a `let popupRoot: Root` in the function
|
||||
* around the one rendering — or held what `createRoot(el)` returned. A JS/TS
|
||||
* receiver the calling function doesn't type is now read by its binding in
|
||||
* scope at the call, as the function's own declaration is: the type `Root`
|
||||
* says the method is react-dom's, and a project type the binding is written
|
||||
* with or makes with `new` is the call's type. A nearer binding of the name —
|
||||
* a parameter, an arrow's or a destructured one — still decides, and another
|
||||
* function's binding holds in that function only. Nor is a class component's
|
||||
* `render()` or lifecycle method a guess for a receiver nothing types: React
|
||||
* calls those.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let roots: string[] = [];
|
||||
let graphs: CodeGraph[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const graph of graphs) graph.close();
|
||||
graphs = [];
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
roots = [];
|
||||
});
|
||||
|
||||
async function index(files: Record<string, string>): Promise<CodeGraph> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-receiver-scope-'));
|
||||
roots.push(root);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const file = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
const graph = await CodeGraph.init(root, { index: true });
|
||||
graphs.push(graph);
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** `caller -> Owner::method` for every `calls` edge out of `file` that lands on a method. */
|
||||
function methodCalls(graph: CodeGraph, file: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const node of graph.getNodesInFile(file)) {
|
||||
for (const e of graph.getOutgoingEdges(node.id)) {
|
||||
if (e.kind !== 'calls') continue;
|
||||
const target = graph.getNode(e.target)!;
|
||||
if (target.kind === 'method') out.push(`${node.name} -> ${target.qualifiedName}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const BOUNDARY = 'import { Component } from "react";\nexport class Boundary extends Component<{ children?: React.ReactNode }> {\n render() { return this.props.children; }\n}\n';
|
||||
|
||||
describe('a receiver is read by its binding in scope at the call', () => {
|
||||
it("links react-dom's root to no project render, however it was declared (#2589)", async () => {
|
||||
const graph = await index({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18.0.0', 'react-dom': '^18.0.0' } }),
|
||||
'src/Boundary.tsx': BOUNDARY,
|
||||
// The issue's reproduction.
|
||||
'src/popup.tsx':
|
||||
'import { createRoot, type Root } from "react-dom/client";\nlet root: Root | null = null;\n' +
|
||||
'export function showPopup(el: HTMLElement) {\n if (!root) root = createRoot(el);\n root.render(<div />);\n}\n',
|
||||
'src/popup2.tsx':
|
||||
'import { createRoot } from "react-dom/client";\nexport function mount(el: HTMLElement) {\n const r = createRoot(el);\n r.render(<div />);\n}\n',
|
||||
'src/popup3.tsx':
|
||||
'import { createRoot, type Root } from "react-dom/client";\nexport function mount3(el: HTMLElement) {\n const r: Root = createRoot(el);\n r.render(<div />);\n}\n' +
|
||||
'export function mount4(root: Root) {\n root.render(<div />);\n}\n',
|
||||
'src/more.tsx':
|
||||
'import { createRoot, type Root } from "react-dom/client";\nlet modRoot: Root;\n' +
|
||||
'export function a1(el: HTMLElement) { modRoot = createRoot(el); modRoot.render(<div />); }\n\n' +
|
||||
'export function outer(el: HTMLElement) {\n let popupRoot: Root | null = null;\n function renderPopup() { popupRoot!.render(<div />); }\n' +
|
||||
' popupRoot = createRoot(el);\n return renderPopup;\n}\n',
|
||||
// Through the default export, at module scope and in a function, and in JavaScript.
|
||||
'src/main.tsx':
|
||||
'import ReactDOM from "react-dom/client";\nconst el = document.getElementById("root")!;\n' +
|
||||
'if (!el.innerHTML) {\n const root = ReactDOM.createRoot(el);\n root.render(<div />);\n}\n' +
|
||||
'const shared = ReactDOM.hydrateRoot(el, <div />);\nexport function rerender() {\n shared.render(<div />);\n}\n',
|
||||
'src/Card.jsx': 'import { Component } from "react";\nexport class Card extends Component {\n render() { return null; }\n}\n',
|
||||
'src/legacy.jsx':
|
||||
'import { createRoot } from "react-dom/client";\nexport function boot(el) {\n const root = createRoot(el);\n root.render(<div />);\n}\n',
|
||||
});
|
||||
for (const file of ['src/popup.tsx', 'src/popup2.tsx', 'src/popup3.tsx', 'src/more.tsx', 'src/main.tsx', 'src/legacy.jsx']) {
|
||||
expect(methodCalls(graph, file), file).toEqual([]);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("takes a project type from a binding outside the calling function", async () => {
|
||||
const graph = await index({
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/queues.ts':
|
||||
'export class Store {\n dispatch(action: string) { return action; }\n}\n' +
|
||||
'export class Queue {\n dispatch(job: string) { return job; }\n}\n',
|
||||
'src/use.ts':
|
||||
'import { Store, Queue } from "./queues";\n' +
|
||||
// A module's binding, named after neither class.
|
||||
'const s = new Store();\nlet q: Queue | undefined;\n' +
|
||||
'export function send() { s.dispatch("a"); }\nexport function drain() { q!.dispatch("b"); }\n' +
|
||||
// The function around the one calling, and its parameter.
|
||||
'export function outer() {\n const pending = new Queue();\n function flush() { pending.dispatch("c"); }\n return flush;\n}\n' +
|
||||
'export function around(svc: Store) {\n function go() { svc.dispatch("d"); }\n return go;\n}\n',
|
||||
});
|
||||
expect(methodCalls(graph, 'src/use.ts')).toEqual([
|
||||
'drain -> Queue::dispatch',
|
||||
'flush -> Queue::dispatch',
|
||||
'go -> Store::dispatch',
|
||||
'send -> Store::dispatch',
|
||||
]);
|
||||
}, 120_000);
|
||||
|
||||
it('lets a nearer binding of the name decide', async () => {
|
||||
const graph = await index({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { 'react-dom': '^18.0.0' } }),
|
||||
// A method a call is guessed to is one of its own language's.
|
||||
'src/chart.tsx': 'export class Chart {\n render() { return 1; }\n}\n',
|
||||
'src/gauge.jsx': 'export class Gauge {\n close() { return 1; }\n}\n',
|
||||
'src/views.tsx':
|
||||
'import { createRoot, type Root } from "react-dom/client";\nimport { Chart } from "./chart";\n' +
|
||||
'let root: Root | null = null;\nexport function init(el: HTMLElement) { root = createRoot(el); }\n' +
|
||||
// A typed parameter, an arrow's own parameter, a destructured one.
|
||||
'export function typed(root: Chart) { root.render(); }\n' +
|
||||
'export function each(items: Chart[]) { items.forEach((root) => root.render()); }\n' +
|
||||
'export function Panel({ root }: { root: any }) { return root.render(); }\n' +
|
||||
// Another function's own declaration holds in that function only.
|
||||
'export function other() { const root = new Chart(); return root; }\n' +
|
||||
'export function show() { root!.render(<div />); }\n',
|
||||
// A `new` of a parameter names no class to look the method up on.
|
||||
'src/later.jsx':
|
||||
'export function make(Backend) {\n const watcher = new Backend();\n function stop() { watcher.close(); }\n return stop;\n}\n',
|
||||
});
|
||||
expect(methodCalls(graph, 'src/views.tsx')).toEqual([
|
||||
'Panel -> Chart::render',
|
||||
'each -> Chart::render',
|
||||
'typed -> Chart::render',
|
||||
]);
|
||||
expect(methodCalls(graph, 'src/later.jsx')).toEqual(['stop -> Gauge::close']);
|
||||
}, 120_000);
|
||||
});
|
||||
+433
-10
@@ -11632,15 +11632,7 @@ function enclosingOpenParen(source: string, at: number): number {
|
||||
* arrow.
|
||||
*/
|
||||
function bindsAtSites(source: string, name: string, sites: LocalBindingSites): boolean {
|
||||
let patterns = LOCAL_BINDING_PATTERNS.get(name);
|
||||
if (!patterns) {
|
||||
patterns = localBindingPatterns(name, 'y');
|
||||
if (LOCAL_BINDING_PATTERNS.size >= LOCAL_BINDING_PATTERNS_CAP) {
|
||||
LOCAL_BINDING_PATTERNS.delete(LOCAL_BINDING_PATTERNS.keys().next().value!);
|
||||
}
|
||||
LOCAL_BINDING_PATTERNS.set(name, patterns);
|
||||
}
|
||||
const { decl, fn, param } = patterns;
|
||||
const { decl, fn, param } = stickyBindingPatterns(name);
|
||||
let from = 0;
|
||||
for (const at of sites.varDecls) {
|
||||
if (at < from) continue;
|
||||
@@ -17848,6 +17840,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
JS_FN_LOCAL_MEMO.delete(context);
|
||||
JS_CODE_BINDS.delete(context);
|
||||
JS_FN_CODE.delete(context);
|
||||
JS_SCOPE_FILES.delete(context);
|
||||
JS_MODULE_VALUES.delete(context);
|
||||
REACT_LIFECYCLE_MEMO.delete(context);
|
||||
TS_FIELD_DECL_MEMO.delete(context);
|
||||
TS_CLASS_LINES.delete(context);
|
||||
TS_PASS_THROUGH_WRAPPERS.delete(context);
|
||||
@@ -17875,6 +17870,13 @@ function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a TS annotation writes, captured: `Logger`, `Repository<User>` (as
|
||||
* `Repository`), and with brackets after it whole — `Item[]`, `Element["ids"]`
|
||||
* — for matchMethodCall to read (arrayElementType, tsIndexedPropertyType).
|
||||
*/
|
||||
const TS_ANNOTATED_TYPE = '([A-Z][\\w.$]*(?:(?:\\s*<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*\\[[^\\]]*\\])?)';
|
||||
|
||||
export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
||||
return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r));
|
||||
}
|
||||
@@ -17908,7 +17910,7 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
|
||||
// can tell an array of it (`items: Item[]`, `items: Item<K>[]`), whose
|
||||
// own methods aren't an element's (arrayElementType), from one of its
|
||||
// properties (`groupIds: Element["groupIds"]`), which isn't it.
|
||||
new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*(?:(?:\\s*<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*\\[[^\\]]*\\])?)`), // lg: Logger (annotation or typed param)
|
||||
new RegExp(`\\b${r}\\b\\s*:\\s*${TS_ANNOTATED_TYPE}`), // lg: Logger (annotation or typed param)
|
||||
];
|
||||
case 'python':
|
||||
return [
|
||||
@@ -18839,6 +18841,373 @@ function resolveAwaitedCallType(
|
||||
return { name: typeNode.name, filePath: typeNode.filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* A JS/TS file's code read for the bindings its names have (jsBindingAt):
|
||||
* comments, strings, template literals and regex literals blanked; where each
|
||||
* line starts; its braces as a tree of scopes, the first the file's own; and
|
||||
* where a `const`/`let`/`var`, a `function`/`class` or an arrow can start.
|
||||
* Per name, the bindings jsBindingSites finds.
|
||||
*/
|
||||
interface JsScopeFile {
|
||||
code: string;
|
||||
lineStarts: number[];
|
||||
scopes: { start: number; end: number; parent: number }[];
|
||||
varDecls: number[];
|
||||
fnDecls: number[];
|
||||
arrows: number[];
|
||||
bindings: Map<string, JsBindingSite[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A binding of a name: where it holds (`from`, its block's `{` or its body's
|
||||
* start, through `to`) and where it is written (`at`). A `const`/`let`/`var`
|
||||
* of the name alone is a declaration, a parameter list's name a parameter,
|
||||
* a `function` or `class` declaration a function. Any other binding — a
|
||||
* destructuring, a `for` head, a lone or destructured arrow parameter — says
|
||||
* nothing of the value. `reading` keeps what the binding says
|
||||
* (jsBindingReading).
|
||||
*/
|
||||
interface JsBindingSite {
|
||||
from: number;
|
||||
to: number;
|
||||
at: number;
|
||||
kind: 'declaration' | 'parameter' | 'function' | 'other';
|
||||
reading?: JsBindingReading | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a binding says its value is: an instance of the type it is written
|
||||
* with, or of the class its whole value makes with `new` (`raw` as written,
|
||||
* on `line`).
|
||||
*/
|
||||
type JsBindingReading = { type: string; raw: string; line: number };
|
||||
|
||||
/** Per context, the files jsScopeFile read last, most recent last. */
|
||||
const JS_SCOPE_FILES = new WeakMap<ResolutionContext, Map<string, JsScopeFile | null>>();
|
||||
const JS_SCOPE_FILES_KEEP = 16;
|
||||
|
||||
function jsScopeFile(filePath: string, context: ResolutionContext): JsScopeFile | null {
|
||||
let files = JS_SCOPE_FILES.get(context);
|
||||
if (!files) JS_SCOPE_FILES.set(context, (files = new Map()));
|
||||
let file = files.get(filePath);
|
||||
if (file !== undefined) {
|
||||
files.delete(filePath);
|
||||
} else {
|
||||
file = null;
|
||||
const source = context.readFile(filePath);
|
||||
// A bundle's names are nothing a person declares a receiver with.
|
||||
if (source !== null && source.length <= 1_000_000 && !isMinifiedContent(filePath, source)) {
|
||||
const regexes: number[] = [];
|
||||
const chars = blankStringContents(stripCommentsForRegex(source, 'javascript'), regexes).split('');
|
||||
for (let r = 0; r + 1 < regexes.length; r += 2) chars.fill(' ', regexes[r]! + 1, regexes[r + 1]!);
|
||||
const code = chars.join('');
|
||||
const lineStarts = [0];
|
||||
const scopes = [{ start: -1, end: code.length, parent: -1 }];
|
||||
const open = [0];
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const c = code[i];
|
||||
if (c === '\n') lineStarts.push(i + 1);
|
||||
else if (c === '{') {
|
||||
scopes.push({ start: i, end: code.length, parent: open[open.length - 1]! });
|
||||
open.push(scopes.length - 1);
|
||||
} else if (c === '}' && open.length > 1) scopes[open.pop()!]!.end = i;
|
||||
}
|
||||
const offsets = (re: RegExp): number[] => Array.from(code.matchAll(re), (m) => m.index!);
|
||||
const arrows: number[] = [];
|
||||
for (let a = code.indexOf('=>'); a !== -1; a = code.indexOf('=>', a + 2)) arrows.push(a);
|
||||
file = { code, lineStarts, scopes, varDecls: offsets(VAR_DECL_SITE), fnDecls: offsets(FN_DECL_SITE), arrows, bindings: new Map() };
|
||||
}
|
||||
if (files.size >= JS_SCOPE_FILES_KEEP) files.delete(files.keys().next().value!);
|
||||
}
|
||||
files.set(filePath, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** The innermost scope of `file` that holds `offset`. */
|
||||
function jsScopeAt(file: JsScopeFile, offset: number): number {
|
||||
const { scopes } = file;
|
||||
let lo = 0, hi = scopes.length;
|
||||
while (lo + 1 < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (scopes[mid]!.start < offset) lo = mid; else hi = mid;
|
||||
}
|
||||
while (lo > 0 && scopes[lo]!.end < offset) lo = scopes[lo]!.parent;
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** localBindingPatterns, sticky, by name. */
|
||||
function stickyBindingPatterns(name: string): LocalBindingPatterns {
|
||||
let patterns = LOCAL_BINDING_PATTERNS.get(name);
|
||||
if (!patterns) {
|
||||
patterns = localBindingPatterns(name, 'y');
|
||||
if (LOCAL_BINDING_PATTERNS.size >= LOCAL_BINDING_PATTERNS_CAP) {
|
||||
LOCAL_BINDING_PATTERNS.delete(LOCAL_BINDING_PATTERNS.keys().next().value!);
|
||||
}
|
||||
LOCAL_BINDING_PATTERNS.set(name, patterns);
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/** Every binding of `name` in the file, read once per file and name. */
|
||||
function jsBindingSites(name: string, file: JsScopeFile): JsBindingSite[] {
|
||||
const known = file.bindings.get(name);
|
||||
if (known) return known;
|
||||
const sites: JsBindingSite[] = [];
|
||||
const { code } = file;
|
||||
const { decl, fn, param } = stickyBindingPatterns(name);
|
||||
const blockOf = (at: number) => {
|
||||
const scope = file.scopes[jsScopeAt(file, at)]!;
|
||||
return { from: scope.start, to: scope.end };
|
||||
};
|
||||
let resume = 0;
|
||||
for (const at of file.varDecls) {
|
||||
if (at < resume) continue;
|
||||
decl.lastIndex = at;
|
||||
const m = decl.exec(code);
|
||||
if (!m) continue;
|
||||
resume = at + m[0].length;
|
||||
if (/\bfor\s*(?:await\s*)?\(\s*$/.test(code.slice(Math.max(0, at - 16), at))) {
|
||||
// A `for` head's binding holds in the head and the statement after it.
|
||||
sites.push({ from: code.lastIndexOf('(', at), to: jsForStatementEnd(code, at), at, kind: 'other' });
|
||||
continue;
|
||||
}
|
||||
// `const x = require('./x')` binds an import, which the file's imports answer for.
|
||||
const plain = !/^(?:const|let|var)\s+[{[]/.test(m[0]) && !IMPORT_BINDING_VALUE.test(m[1] ?? '');
|
||||
sites.push({ ...blockOf(at), at, kind: plain ? 'declaration' : 'other' });
|
||||
}
|
||||
for (const at of file.fnDecls) {
|
||||
fn.lastIndex = at;
|
||||
if (fn.test(code)) sites.push({ ...blockOf(at), at, kind: 'function' });
|
||||
}
|
||||
const n = escapeRegExp(name);
|
||||
const paramName = new RegExp(`(?:^\\(|,)\\s*${PARAM_MODIFIERS}(?:\\.\\.\\.)?(${n})(?![\\w$])`);
|
||||
let tried = -1;
|
||||
for (let at = code.indexOf(name); at !== -1; at = code.indexOf(name, at + 1)) {
|
||||
// A whole name, and no member's (`el.root`), though a rest parameter's (`...root`) is one.
|
||||
if ((at > 0 && /[\w$]/.test(code[at - 1]!)) || /[\w$]/.test(code[at + name.length] ?? '') ||
|
||||
(code[at - 1] === '.' && code.slice(Math.max(0, at - 3), at) !== '...')) continue;
|
||||
const open = enclosingOpenParen(code, at);
|
||||
// A control-flow head, `if (root) {`, binds nothing.
|
||||
if (open >= 0 && /\b(?:if|while|for|switch|with)\s*$/.test(code.slice(Math.max(0, open - 12), open))) continue;
|
||||
if (open < 0) {
|
||||
// A destructured parameter: `({ root }) => …`, `function f([root]) {`.
|
||||
const body = destructuredParameterBody(code, at);
|
||||
if (body) sites.push({ ...body, at, kind: 'other' });
|
||||
continue;
|
||||
}
|
||||
if (open === tried) continue;
|
||||
tried = open;
|
||||
param.lastIndex = open;
|
||||
const m = param.exec(code);
|
||||
if (!m) continue;
|
||||
const end = open + m[0].length;
|
||||
const own = paramName.exec(m[0]);
|
||||
sites.push({
|
||||
...(code[end - 1] === '{' ? { from: end - 1, to: jsBlockEnd(code, end) } : { from: end, to: jsBodyEnd(code, end) }),
|
||||
at: own ? open + own.index + own[0].length - name.length : at,
|
||||
kind: 'parameter',
|
||||
});
|
||||
}
|
||||
// `name =>`: the name ends where the whitespace before the arrow starts.
|
||||
for (const arrow of file.arrows) {
|
||||
let end = arrow;
|
||||
while (end > 0 && WHITESPACE.test(code[end - 1]!)) end--;
|
||||
const start = end - name.length;
|
||||
if (start >= 0 && code.startsWith(name, start) && (start === 0 || !ARROW_HEAD_CHAR.test(code[start - 1]!))) {
|
||||
sites.push({ from: arrow + 2, to: jsBodyEnd(code, arrow + 2), at: start, kind: 'other' });
|
||||
}
|
||||
}
|
||||
file.bindings.set(name, sites);
|
||||
return sites;
|
||||
}
|
||||
|
||||
/** Where a `for` statement whose head holds `at` ends: past the statement after its head. */
|
||||
function jsForStatementEnd(code: string, at: number): number {
|
||||
let depth = 0;
|
||||
for (let i = at; i < code.length; i++) {
|
||||
const c = code[i];
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if ((c === ')' || c === ']' || c === '}') && --depth < 0) return c === ')' ? jsBodyEnd(code, i + 1) : i;
|
||||
}
|
||||
return code.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of the function whose parameter list destructures the name at
|
||||
* `at` (`({ a, b: [root] }) => …`), or null when the name is in no
|
||||
* parameter list: read back out of the braces and brackets around it to the
|
||||
* `(` that opens the list, which a `=>` or a body's `{` follows (past a
|
||||
* return type) and no `if`, `while`, `for`, `switch` or `with` heads.
|
||||
*/
|
||||
function destructuredParameterBody(code: string, at: number): { from: number; to: number } | null {
|
||||
let depth = 0;
|
||||
let nested = false;
|
||||
let open = -1;
|
||||
for (let i = at - 1; i >= 0 && at - i <= 2000; i--) {
|
||||
const c = code[i];
|
||||
if (c === ')' || c === ']' || c === '}') depth++;
|
||||
else if (c === '(' || c === '[' || c === '{') {
|
||||
if (depth > 0) {
|
||||
depth--;
|
||||
} else if (c === '(') {
|
||||
open = i;
|
||||
break;
|
||||
} else {
|
||||
nested = true;
|
||||
}
|
||||
} else if (c === ';' && depth === 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!nested || open < 0 || /\b(?:if|while|for|switch|with)\s*$/.test(code.slice(Math.max(0, open - 12), open))) return null;
|
||||
let close = open + 1;
|
||||
for (let d = 1; close < code.length && d > 0; close++) {
|
||||
if (code[close] === '(') d++;
|
||||
else if (code[close] === ')') d--;
|
||||
}
|
||||
const head = /^\s*(?::(?:[^=;{}()\n]|\{(?:[^{}]|\{[^{}]*\})*\})*)?(=>|\{)/.exec(code.slice(close, close + 2000));
|
||||
if (!head) return null;
|
||||
const end = close + head[0].length;
|
||||
return head[1] === '{' ? { from: end - 1, to: jsBlockEnd(code, end) } : { from: end, to: jsBodyEnd(code, end) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The binding of `name` in scope at `offset` of a JS/TS file: the one whose
|
||||
* scope holds it innermost, and of two in one scope the declaration nearest
|
||||
* before it. Null when no binding of the name holds there.
|
||||
*/
|
||||
function jsBindingAt(name: string, file: JsScopeFile, offset: number): JsBindingSite | null {
|
||||
let best: JsBindingSite | null = null;
|
||||
for (const site of jsBindingSites(name, file)) {
|
||||
if (site.from > offset || site.to < offset) continue;
|
||||
if (!best || site.from > best.from ||
|
||||
(site.from === best.from && (site.at <= offset ? best.at > offset || site.at > best.at : best.at > offset && site.at < best.at))) {
|
||||
best = site;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a binding of `name` says its value is (JsBindingReading), or null when
|
||||
* it says nothing: a declaration or a parameter written with a type, or a
|
||||
* declaration whose whole value is a `new` of a class.
|
||||
*/
|
||||
function jsBindingReading(name: string, site: JsBindingSite, file: JsScopeFile): JsBindingReading | null {
|
||||
if (site.reading === undefined) {
|
||||
site.reading = site.kind === 'parameter' ? jsParameterReading(name, site, file)
|
||||
: site.kind === 'declaration' ? jsDeclarationReading(name, site, file)
|
||||
: null;
|
||||
}
|
||||
return site.reading;
|
||||
}
|
||||
|
||||
/** The 1-based line of an offset in `file`. */
|
||||
function jsLineOf(file: JsScopeFile, offset: number): number {
|
||||
let lo = 0, hi = file.lineStarts.length;
|
||||
while (lo + 1 < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (file.lineStarts[mid]! <= offset) lo = mid; else hi = mid;
|
||||
}
|
||||
return lo + 1;
|
||||
}
|
||||
|
||||
/** The type a parameter is written with (`root: Root`, `svc?: Store`). */
|
||||
function jsParameterReading(name: string, site: JsBindingSite, file: JsScopeFile): JsBindingReading | null {
|
||||
const typed = new RegExp(`${escapeRegExp(name)}(?![\\w$])\\s*\\??\\s*:\\s*${TS_ANNOTATED_TYPE}`, 'y');
|
||||
typed.lastIndex = site.at;
|
||||
const raw = typed.exec(file.code)?.[1];
|
||||
const type = raw ? normalizeInferredTypeName(raw) : null;
|
||||
return raw && type ? { type, raw, line: jsLineOf(file, site.at) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a `const`/`let`/`var` says its value is: the type it is written with,
|
||||
* or the class its whole value makes with `new` — not a `new` of a
|
||||
* parameter or variable (`new Backend(root)`), which names no class.
|
||||
*/
|
||||
function jsDeclarationReading(name: string, site: JsBindingSite, file: JsScopeFile): JsBindingReading | null {
|
||||
const { code } = file;
|
||||
const head = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(name)}(?![\\w$])\\s*(?:!?\\s*:\\s*${TS_ANNOTATED_TYPE})?`, 'y');
|
||||
head.lastIndex = site.at;
|
||||
const m = head.exec(code);
|
||||
if (!m) return null;
|
||||
const valueAt = site.at + m[0].length;
|
||||
const rest = code.slice(valueAt, valueAt + 400);
|
||||
// An object literal is its own members' (resolveObjectPathMember), whatever
|
||||
// type it is written with: vite's `const server: ViteDevServer = { … }` is
|
||||
// the `close` it declares, not the interface's.
|
||||
if (/^[^=;]*=(?![=>])\s*\{/.test(rest)) return null;
|
||||
const line = jsLineOf(file, site.at);
|
||||
if (m[1]) {
|
||||
const type = normalizeInferredTypeName(m[1]);
|
||||
return type ? { type, raw: m[1], line } : null;
|
||||
}
|
||||
const made = /^\s*=\s*new\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*(?:<[^;(){}]*>\s*)?\(/.exec(rest);
|
||||
if (!made) return null;
|
||||
const className = made[1]!.replace(/\s+/g, '');
|
||||
const bound = jsBindingAt(className.split('.')[0]!, file, valueAt);
|
||||
if (bound && bound.kind !== 'function') return null;
|
||||
// The `new` is the whole value: no member, index, call or operator follows it.
|
||||
let close = valueAt + made[0].length;
|
||||
let depth = 1;
|
||||
for (const limit = close + 5000; close < code.length && close < limit && depth > 0; close++) {
|
||||
if (code[close] === '(') depth++;
|
||||
else if (code[close] === ')') depth--;
|
||||
}
|
||||
if (depth > 0 || !/^[ \t]*(?:[;,)}]|\r?\n(?![ \t]*(?:[.(\[?:]|\?\?|\|\||&&|as\b))|$)/.test(code.slice(close, close + 200))) return null;
|
||||
const type = normalizeInferredTypeName(className);
|
||||
return type ? { type, raw: className, line } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per context and file: how many functions and methods hold each line, and
|
||||
* the names the file's module scope gives a variable or constant node.
|
||||
*/
|
||||
const JS_MODULE_VALUES = new WeakMap<ResolutionContext, Map<string, { depth: Int32Array; names: Set<string> }>>();
|
||||
|
||||
/**
|
||||
* Whether the binding a call's receiver has may lie outside the function
|
||||
* making the call, where the calling function's own declarations, which the
|
||||
* receiver reader has read already, can't say what it is: in a function
|
||||
* around that one, or at module scope, which declares the name. Code outside
|
||||
* any function was read from the file's start.
|
||||
*/
|
||||
function jsMayBindOutside(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
let files = JS_MODULE_VALUES.get(context);
|
||||
if (!files) JS_MODULE_VALUES.set(context, (files = new Map()));
|
||||
let scope = files.get(ref.filePath);
|
||||
if (!scope) {
|
||||
const nodes = context.getNodesInFile(ref.filePath);
|
||||
const functions = nodes.filter((n) => n.kind === 'function' || n.kind === 'method');
|
||||
const depth = new Int32Array(functions.reduce((last, n) => Math.max(last, n.endLine), 0) + 2);
|
||||
for (const n of functions) {
|
||||
depth[n.startLine]!++;
|
||||
depth[n.endLine + 1]!--;
|
||||
}
|
||||
for (let line = 1; line < depth.length; line++) depth[line]! += depth[line - 1]!;
|
||||
const names = new Set(nodes.filter((n) => (n.kind === 'variable' || n.kind === 'constant') && n.qualifiedName === n.name).map((n) => n.name));
|
||||
if (files.size >= 64) files.delete(files.keys().next().value!);
|
||||
files.set(ref.filePath, (scope = { depth, names }));
|
||||
}
|
||||
const around = scope.depth[ref.line] ?? 0;
|
||||
return around > 1 || (around === 1 && scope.names.has(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* A JS/TS receiver the calling function doesn't type, read by its binding in
|
||||
* scope at the call (#2589): a module's `let root: Root | null`, a `let
|
||||
* popupRoot: Root` in the function around the one calling, or an outer
|
||||
* function's typed parameter. Null when the binding says nothing of its value.
|
||||
*/
|
||||
function jsReceiverBindingReading(receiver: string, ref: UnresolvedRef, context: ResolutionContext): JsBindingReading | null {
|
||||
if (!jsMayBindOutside(receiver, ref, context)) return null;
|
||||
const file = jsScopeFile(ref.filePath, context);
|
||||
if (!file) return null;
|
||||
const site = jsBindingAt(receiver, file, (file.lineStarts[ref.line - 1] ?? file.code.length) + ref.column);
|
||||
return site ? jsBindingReading(receiver, site, file) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns that recover a PHP class property's declared type for a
|
||||
* `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
|
||||
@@ -19192,6 +19561,22 @@ export function matchMethodCall(
|
||||
if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null;
|
||||
inferredType = awaited.name;
|
||||
}
|
||||
// A TS/JS receiver the calling function doesn't type is read by its
|
||||
// binding in scope at the call (#2589): a module's `let root: Root |
|
||||
// null`, a `let popupRoot: Root` in the function around the one calling,
|
||||
// or an outer function's typed parameter types it, as the calling
|
||||
// function's own declaration would.
|
||||
if (!inferredType && !awaited && dotMatch && /^[A-Za-z_$][\w$]*$/.test(objectOrClass!) &&
|
||||
(ref.language === 'typescript' || ref.language === 'tsx' || ref.language === 'javascript' || ref.language === 'jsx') &&
|
||||
objectOrClass !== 'this' && objectOrClass !== 'super' && !isImportBinding(objectOrClass!, ref, context) &&
|
||||
isLocallyBoundJsName(objectOrClass!, ref.filePath, context)) {
|
||||
const reading = nmTimedT('mc-jsbinding', ref, () => jsReceiverBindingReading(objectOrClass!, ref, context));
|
||||
if (reading) {
|
||||
inferredType = reading.type;
|
||||
decl.raw = reading.raw;
|
||||
decl.line = reading.line;
|
||||
}
|
||||
}
|
||||
// A TS annotation with brackets after the type. An array of it (`items:
|
||||
// Item[]`): a method arrays have is the array's, which no project class
|
||||
// declares, where `distinctElements.find(…)` on vscode's `ExplorerItem[]`
|
||||
@@ -19731,6 +20116,14 @@ export function matchMethodCall(
|
||||
narrowed ||= kept.length !== targetMethods.length;
|
||||
targetMethods = kept;
|
||||
}
|
||||
// A React class component's `render()` and lifecycle methods are React's
|
||||
// to call (#2589): react-dom's `root.render(<App />)` is no error
|
||||
// boundary's `render`, whatever holds the root.
|
||||
if (JS_FAMILY.has(ref.language) && objectOrClass !== 'this' && objectOrClass !== 'super') {
|
||||
const kept = targetMethods.filter((m) => !isReactLifecycleMethod(m, context));
|
||||
narrowed ||= kept.length !== targetMethods.length;
|
||||
targetMethods = kept;
|
||||
}
|
||||
|
||||
// A python attribute chain nothing typed (matchPythonAttrCall) is guessed
|
||||
// only by the name of its last link, in the scoring below — Django's
|
||||
@@ -19871,6 +20264,36 @@ function isImportedModuleReceiver(receiver: string, ref: UnresolvedRef, context:
|
||||
return binding.isNamespace || context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true;
|
||||
}
|
||||
|
||||
/** The methods React calls on a class component itself. */
|
||||
const REACT_LIFECYCLE_METHODS: ReadonlySet<string> = new Set([
|
||||
'render', 'componentDidMount', 'componentDidUpdate', 'componentWillUnmount', 'shouldComponentUpdate',
|
||||
'getSnapshotBeforeUpdate', 'componentDidCatch', 'componentWillMount', 'componentWillReceiveProps',
|
||||
'componentWillUpdate', 'UNSAFE_componentWillMount', 'UNSAFE_componentWillReceiveProps', 'UNSAFE_componentWillUpdate',
|
||||
]);
|
||||
const REACT_LIFECYCLE_MEMO = new WeakMap<ResolutionContext, Map<string, boolean>>();
|
||||
|
||||
/**
|
||||
* A lifecycle method (`render`, `componentDidMount`, …) of a class that
|
||||
* extends React's `Component` or `PureComponent` (or Preact's), read from the
|
||||
* class's head.
|
||||
*/
|
||||
function isReactLifecycleMethod(n: Node, context: ResolutionContext): boolean {
|
||||
if (n.kind !== 'method' || !REACT_LIFECYCLE_METHODS.has(n.name)) return false;
|
||||
let memo = REACT_LIFECYCLE_MEMO.get(context);
|
||||
if (!memo) REACT_LIFECYCLE_MEMO.set(context, (memo = new Map()));
|
||||
let hit = memo.get(n.id);
|
||||
if (hit === undefined) {
|
||||
const ownerName = n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split('::').pop();
|
||||
const owner = ownerName ? context.getNodesInFile(n.filePath).find((c) =>
|
||||
c.kind === 'class' && c.name === ownerName && c.startLine <= n.startLine && c.endLine >= n.endLine) : undefined;
|
||||
const lines = owner ? context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? [] : [];
|
||||
const head = owner ? lines.slice(owner.startLine - 1, Math.min(owner.startLine + 3, n.startLine)).join(' ').split('{')[0]! : '';
|
||||
hit = /\bextends\s+(?:React\s*\.\s*)?(?:Pure)?Component\b/.test(head);
|
||||
memo.set(n.id, hit);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** A method a Vue Options API component declares for itself (`index::handleLogin` in `index.vue`). */
|
||||
function isVueComponentMethod(n: Node): boolean {
|
||||
return n.kind === 'method' && n.filePath.endsWith('.vue') && n.qualifiedName === `${path.posix.basename(n.filePath, '.vue')}::${n.name}`;
|
||||
|
||||
Reference in New Issue
Block a user