diff --git a/CHANGELOG.md b/CHANGELOG.md index cc671ae623..047fb9d5e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) - In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) - In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#2322) +- Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334) +- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts index 5b551c136c..857937d0c8 100644 --- a/__tests__/integration/full-pipeline.test.ts +++ b/__tests__/integration/full-pipeline.test.ts @@ -15,7 +15,7 @@ * crawl). */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -269,4 +269,53 @@ describe('Integration: full pipeline', () => { cg.destroy(); } }, 30_000); + + it('reports durationMs for the whole run, resolution and linking included (#2334)', async () => { + // The CLI summary printed the orchestrator's time — extraction alone — + // beside the totals of the whole run, so slow resolution never showed. + generateSyntheticProject(tempDir, 10); + const cg = await CodeGraph.init(tempDir, { + config: { include: ['**/*.ts'], exclude: [] }, + }); + const internals = cg as unknown as { + orchestrator: Record<'indexAll' | 'sync', (...args: unknown[]) => Promise<{ durationMs: number }>>; + resolver: { resolveDeferredThisMemberRefs: () => Promise }; + }; + // What the orchestrator measured; and a linking pass that takes a known while. + let extraction = -1; + const measure = (method: 'indexAll' | 'sync') => { + const original = internals.orchestrator[method].bind(internals.orchestrator); + vi.spyOn(internals.orchestrator, method).mockImplementation(async (...args: unknown[]) => { + const result = await original(...args); + extraction = result.durationMs; + return result; + }); + }; + const LINKING_MS = 250; + const linking = internals.resolver.resolveDeferredThisMemberRefs.bind(internals.resolver); + vi.spyOn(internals.resolver, 'resolveDeferredThisMemberRefs').mockImplementation(async () => { + const until = Date.now() + LINKING_MS; + while (Date.now() < until) await new Promise((r) => setTimeout(r, 10)); + return linking(); + }); + + try { + measure('indexAll'); + const indexed = await cg.indexAll(); + expect(extraction).toBeGreaterThanOrEqual(0); + expect(indexed.durationMs).toBeGreaterThanOrEqual(extraction + LINKING_MS); + + // Likewise a sync that re-resolves a changed file. + fs.appendFileSync(path.join(tempDir, 'src', 'index.ts'), 'export function again(): number { return entry(); }\n'); + extraction = -1; + measure('sync'); + const synced = await cg.sync(); + expect(synced.filesModified).toBe(1); + expect(extraction).toBeGreaterThanOrEqual(0); + expect(synced.durationMs).toBeGreaterThanOrEqual(extraction + LINKING_MS); + } finally { + vi.restoreAllMocks(); + cg.destroy(); + } + }, 30_000); }); diff --git a/__tests__/js-resolution-work.test.ts b/__tests__/js-resolution-work.test.ts new file mode 100644 index 0000000000..37a976d736 --- /dev/null +++ b/__tests__/js-resolution-work.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { stripCommentsForRegex } from '../src/resolution/strip-comments'; + +// Pass-through, so a test can count how often text is stripped. +vi.mock('../src/resolution/strip-comments', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, stripCommentsForRegex: vi.fn(actual.stripCommentsForRegex) }; +}); + +/** + * #2334: resolving JS/TS does a bounded amount of work per file. In a file + * with destructuring, the check for a call through a destructured name + * stripped and scanned every line above each bare call; the check for a name + * the calling function binds itself stripped that function's lines above each + * reference again. A bundled library took time in the square of its size. + * Counted, never timed. + */ +describe('JS resolution work (#2334)', () => { + let tmpDir: string | undefined; + let cg: CodeGraph | undefined; + + afterEach(() => { + vi.mocked(stripCommentsForRegex).mockClear(); + cg?.close(); + cg = undefined; + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 }); + tmpDir = undefined; + }); + + async function index(files: Record): Promise { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-work-')); + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(tmpDir, file)), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, file), content); + } + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + return cg; + } + + /** How often a non-empty start of `text` was stripped as `lang`. */ + const strips = (lang: string, text: string) => vi.mocked(stripCommentsForRegex).mock.calls + .filter(([stripped, as]) => as === lang && stripped.length > 0 && text.startsWith(stripped)).length; + + const callers = (graph: CodeGraph, file: string, name: string) => { + const target = graph.getNodesByName(name).find((n) => n.filePath === file && n.kind === 'function')!; + return [...new Set(graph.getIncomingEdges(target.id).filter((e) => e.kind === 'calls') + .map((e) => graph.getNode(e.source)?.name))]; + }; + + it('reads a file with destructuring once, not once per call', async () => { + const CALLS = 40; + const app = `import { useAuth } from './auth';\n` + + `const { login } = useAuth();\n` + + Array.from({ length: CALLS }, (_, i) => `function step${i}() { return ${i}; }\n`).join('') + + `export function run() {\n` + + Array.from({ length: CALLS }, (_, i) => ` step${i}();\n`).join('') + + // A division the blanking reads as a regex literal around the call. + ` return (${CALLS}) / login() / 2;\n}\n`; + const graph = await index({ + 'auth.js': 'export function useAuth() {\n function login() {\n return 1;\n }\n return { login };\n}\n', + 'app.js': app, + }); + // The call through the destructured name reaches the function the hook returns. + expect(callers(graph, 'auth.js', 'login')).toEqual(['run']); + expect(callers(graph, 'app.js', 'step7')).toEqual(['run']); + // Each bare call was checked; the file was stripped for that once, not once per call. + expect(strips('typescript', app)).toBeLessThan(CALLS / 4); + }); + + it("strips a function's lines once, not once per reference", async () => { + const LINES = 40; + const host = `export function host() {\n` + + Array.from({ length: LINES }, (_, i) => ` helper(${i});\n`).join('') + `}\n`; + const graph = await index({ 'lib.js': `export function helper(n) {\n return n;\n}\n${host}` }); + // Each call resolved, so each asked whether `host` binds `helper` itself. + expect(callers(graph, 'lib.js', 'helper')).toEqual(['host']); + expect(strips('javascript', host)).toBeLessThan(LINES / 4); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index ecb946eafb..8b7da4c2cf 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -114,6 +114,12 @@ export interface IndexResult { nodesCreated: number; edgesCreated: number; errors: ExtractionError[]; + /** + * Wall time in milliseconds. `CodeGraph.indexAll` reports the whole run — + * scanning, parsing and storing, then resolving references and linking — + * as it does the node and edge totals; the orchestrator's own result + * covers only the files' extraction. + */ durationMs: number; } @@ -132,6 +138,11 @@ export interface SyncResult { filesModified: number; filesRemoved: number; nodesUpdated: number; + /** + * Wall time in milliseconds. `CodeGraph.sync` reports the whole sync, + * resolution and linking included; the orchestrator's own result covers + * only reconciling and re-extracting the files. + */ durationMs: number; changedFilePaths?: string[]; /** Paths not absorbed because reading or extraction failed; retain for status/retry. */ diff --git a/src/index.ts b/src/index.ts index 657bbc65ef..7e7a1dbf9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -514,6 +514,7 @@ export class CodeGraph { */ async indexAll(options: IndexOptions = {}): Promise { return this.indexMutex.withLock(async () => { + const startedAt = Date.now(); try { this.fileLock.acquire(); } catch { @@ -775,6 +776,10 @@ export class CodeGraph { } } catch { /* metadata is advisory — never fail an index over it */ } + // The time covers the whole run, like the totals above. The + // orchestrator's covers extraction alone, and the summary printed it + // as the run's, leaving out resolution and linking (#2334). + result.durationMs = Date.now() - startedAt; return result; } finally { // Restore the auto-checkpoint interval AFTER the fold-up above so the @@ -827,6 +832,7 @@ export class CodeGraph { */ async sync(options: IndexOptions = {}): Promise { return this.indexMutex.withLock(async () => { + const startedAt = Date.now(); try { this.fileLock.acquire(); } catch (err) { @@ -1146,6 +1152,8 @@ export class CodeGraph { this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths); if (fullReconcile && result.filesChecked > 0) this.pendingFullReconcile = false; + // The whole sync, as for indexAll: resolution and linking included (#2334). + result.durationMs = Date.now() - startedAt; return result; } finally { // Mirror indexAll's teardown: stop the valve, then restore the diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 87689060ae..15503a4e86 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5509,9 +5509,30 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut if (hit !== undefined) return hit; let scope: { start: number; end: number } | null = null; const fn = context.getNodeById?.(ref.fromNodeId); - if (fn && (fn.kind === 'function' || fn.kind === 'method') && fn.startLine <= ref.line && fn.endLine >= ref.line) { - const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []; - const text = stripCommentsForRegex(lines.slice(fn.startLine - 1, ref.line).join('\n'), 'javascript'); + if (fn && (fn.kind === 'function' || fn.kind === 'method') && fn.startLine <= ref.line && fn.endLine >= ref.line && + jsCodeBindsName(name, fn, ref, context)) { + scope = { start: fn.startLine, end: fn.endLine }; + } + memo.set(key, scope); + return scope; +} + +/** + * Per context, by `file\0first line\0reference line\0name`: whether the code + * from a function's first line through a reference's binds the name. Every + * function that starts on the same line has that code — all of a minified + * script's do — so they share the answer (#2334). + */ +const JS_CODE_BINDS = new WeakMap>(); + +/** Whether the function's code through the reference's line declares `name`, or names it in a parameter list. */ +function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + let memo = JS_CODE_BINDS.get(context); + if (!memo) JS_CODE_BINDS.set(context, (memo = new Map())); + const key = `${ref.filePath}\0${fn.startLine}\0${ref.line}\0${name}`; + let binds = memo.get(key); + if (binds === undefined) { + const text = jsFunctionCodeThrough(fn, ref, context); const { param } = localBindingPatterns(name, 'g'); const n = name.replace(/\$/g, '\\$'); // A plain declaration. Destructuring re-binds what a call returns under @@ -5521,10 +5542,46 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut // A parameter list — never a control-flow head (`if (openMarkerClose) {`). // A return type stays on its line, never a ternary's `: data.slice()` below `filter(canRowExpand)`. const parameter = new RegExp(`(?>(); +const JS_FN_CODE_KEEP = 64; + +/** + * The function's lines from its first through the reference's, comments + * blanked. Stripping looks at most one character ahead — past a line's end, + * a newline, which completes no comment marker — so this is the whole + * function's stripped text cut at that line's end. Stripping the lines again + * for every reference took time in the square of a large function's length; + * in a minified script, every function's text runs to the end of its one + * line (#2334). + */ +function jsFunctionCodeThrough(fn: Node, ref: UnresolvedRef, context: ResolutionContext): string { + let fns = JS_FN_CODE.get(context); + if (!fns) JS_FN_CODE.set(context, (fns = new Map())); + const key = `${ref.filePath}\0${fn.startLine}\0${fn.endLine}`; + let own = fns.get(key); + if (own) { + fns.delete(key); + } else { + const lines = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []) + .slice(fn.startLine - 1, fn.endLine); + let end = -1; + own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((line) => (end += line.length + 1)) }; + if (fns.size >= JS_FN_CODE_KEEP) fns.delete(fns.keys().next().value!); + } + fns.set(key, own); + // Through the reference's line, or the file's last when it ends sooner. + const last = Math.min(ref.line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine; + return last < 0 ? '' : own.code.slice(0, own.lineEnds[last]); } const LOCAL_BINDING_MEMO = new WeakMap>(); @@ -7017,6 +7074,10 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { LOCAL_BINDING_SITES.delete(context); SELECTOR_NAMES.delete(context); GET_STATE_FILES.delete(context); + DESTRUCTURED_FILES.delete(context); + JS_FN_LOCAL_MEMO.delete(context); + JS_CODE_BINDS.delete(context); + JS_FN_CODE.delete(context); TS_FIELD_DECL_MEMO.delete(context); TS_CLASS_LINES.delete(context); TARGET_LANGUAGE.delete(context); @@ -9366,15 +9427,225 @@ function matchDestructuredStoreCall(ref: UnresolvedRef, context: ResolutionConte * and its source must return the key; a later declaration of the name at the * call's scope shadows the binding. The local binding otherwise ruled out * every cross-file candidate, so the call resolved to nothing. + * + * The file is read once (#2334): stripping and scanning all the text above + * each call took time in the square of the file's size — seconds for each + * bundled library (pdf.js, d3) a project ships. The text above a call, + * blanked on its own, is the file's blanked code up to the call: blanking + * reads past a character only for a comment opener's second character (a + * call's name never starts with one) and for the `/` that closes a regex + * literal (calls inside one: matchDestructuredCallInLiteral). Its bindings + * are then the file's that end at or before the call: the pattern finds a + * statement the same way wherever the two texts agree up to its `(`, and + * one running past the call holds no `{` followed by a `(` to start another. */ function matchDestructuredCallResult(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { const source = context.readFile(ref.filePath); - if (!source || !/\b(?:const|let|var)\s*\{/.test(source)) return null; - const name = ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!source) return null; + const file = destructuredBindings(ref.filePath, source, context); + if (!file) return null; + const cut = destructuredCallOffset(file, source, ref); + if (cut < 0) return matchDestructuredCallAbove(ref, source, context); + const literal = regexLiteralAround(file.literals, cut); + if (literal >= 0) { + const depth = file.literalDepths[literal]!; + return depth < 0 ? matchDestructuredCallAbove(ref, source, context) + : matchDestructuredCallInLiteral(ref, file, file.literals[2 * literal]!, depth, cut, context); + } + const bindings = file.byName.get(ref.referenceName) ?? []; + // The nearest binding above the call whose block is still open at the call. + for (let i = bindings.length - 1; i >= 0; i--) { + const { binding, key } = bindings[i]!; + if (binding.end > cut || binding.scopeEnd < cut) continue; + return destructuredCallTarget(binding.callee, key, file.code.slice(binding.end, cut), ref, context); + } + return null; +} + +/** A `const { … } = f(…)` statement. */ +interface DestructuredBinding { + /** Just past its `(`. */ + end: number; + /** The function it calls. */ + callee: string; + /** The `{` of the block it sits in: its place on the stack of open blocks (-1 at top level), and where its `}` is (Infinity if nowhere). */ + depth: number; + scopeEnd: number; +} + +/** What matchDestructuredCallResult needs of a file, read once. */ +interface DestructuredFile { + /** The file with comments and string contents blanked. */ + code: string; + /** Where each line starts. */ + lineStarts: number[]; + /** Each name a binding introduces → those bindings, in file order, with the key it reads. */ + byName: Map>; + /** The opening and closing offset of each span the blanking read as a regex literal, in order. */ + literals: number[]; + /** Per literal: how many blocks are open at its `/`, or -1 when a binding statement could run across that `/`. */ + literalDepths: number[]; +} + +const DESTRUCTURED_BINDING = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*\(/g; +/** Per context: the files matchDestructuredCallResult read last (calls arrive file by file); null for one without destructuring. */ +const DESTRUCTURED_FILES = new WeakMap>(); +const DESTRUCTURED_FILES_KEEP = 16; + +function destructuredBindings(filePath: string, source: string, context: ResolutionContext): DestructuredFile | null { + let files = DESTRUCTURED_FILES.get(context); + if (!files) DESTRUCTURED_FILES.set(context, (files = new Map())); + let file = files.get(filePath); + if (file !== undefined) return file; + file = /\b(?:const|let|var)\s*\{/.test(source) ? readDestructuredBindings(source) : null; + if (files.size >= DESTRUCTURED_FILES_KEEP) files.delete(files.keys().next().value!); + files.set(filePath, file); + return file; +} + +function readDestructuredBindings(source: string): DestructuredFile { + const literals: number[] = []; + const code = blankStringContents(stripCommentsForRegex(source, 'typescript'), literals); + const lineStarts = [0]; + for (let at = source.indexOf('\n'); at !== -1; at = source.indexOf('\n', at + 1)) lineStarts.push(at + 1); + const found = Array.from(code.matchAll(DESTRUCTURED_BINDING), (m) => ({ + index: m.index!, + binding: { end: m.index! + m[0].length, callee: m[2]!, depth: -1, scopeEnd: Infinity }, + keys: destructuredKeys(m[1]!), + })); + // The `{` after every `const`, `let` and `var`: where a binding statement's pattern opens. + const patterns = new Set(Array.from(code.matchAll(/\b(?:const|let|var)\s*\{/g), (m) => m.index! + m[0].length - 1)); + const literalDepths: number[] = []; + const stack: number[] = []; + const scoped = new Map(); + let lastBrace = -1; + let lastBracket = -1; // the last `<`, `>`, `(` or `)` + for (let i = 0, next = 0, literal = 0; i < code.length; i++) { + if (found[next]?.index === i) { + const { binding } = found[next++]!; + // In scope until the `}` that closes the block it sits in. + binding.depth = stack.length - 1; + const open = stack[binding.depth]; + if (open !== undefined) { + const list = scoped.get(open); + if (list) list.push(binding); + else scoped.set(open, [binding]); + } + } + if (literals[2 * literal] === i) { + // A binding statement holds a `/` only in its pattern `{ … }` or its type arguments `< … >`. + literalDepths.push(patterns.has(lastBrace) || code[lastBracket] === '<' ? -1 : stack.length); + literal++; + } + const c = code.charCodeAt(i); + if (c === 123 /* { */) { + stack.push(i); + lastBrace = i; + } else if (c === 125 /* } */) { + lastBrace = i; + const open = stack.pop(); + for (const binding of (open !== undefined && scoped.get(open)) || []) binding.scopeEnd = i; + } else if (c === 60 /* < */ || c === 62 /* > */ || c === 40 /* ( */ || c === 41 /* ) */) { + lastBracket = i; + } + } + const byName = new Map>(); + for (const { binding, keys } of found) { + for (const [name, key] of keys) { + const list = byName.get(name); + if (list) list.push({ binding, key }); + else byName.set(name, [{ binding, key }]); + } + } + return { code, lineStarts, byName, literals, literalDepths }; +} + +/** Each name a destructuring pattern binds → the key it reads (`{ a, b: c = 1 }`); a later entry for a name wins. */ +function destructuredKeys(pattern: string): Map { + const keys = new Map(); + for (const part of pattern.split(',')) { + const [k, v] = part.split(':').map((x) => x.trim().replace(/\s*=.*$/, '')); + if (/^[A-Za-z_$][\w$]*$/.test(k ?? '')) keys.set(v ?? k!, k!); + } + return keys; +} + +/** + * Where the text above a call ends in `source` (that text is + * `source.slice(0, cut)`), or -1 when the call's line or column lies outside + * the file, or that text ends in a `/` that opens a comment in the file. + */ +function destructuredCallOffset(file: DestructuredFile, source: string, ref: UnresolvedRef): number { + const { line, column } = ref; + const starts = file.lineStarts; + if (!Number.isInteger(line) || !Number.isInteger(column) || line < 1 || line > starts.length || column < 0) return -1; + const cut = Math.min(starts[line - 1]! + column, line < starts.length ? starts[line]! - 1 : source.length); + return source[cut - 1] === '/' && (source[cut] === '/' || source[cut] === '*') ? -1 : cut; +} + +/** The regex literal (its index) that opens before `cut` and closes at or after it, or -1. */ +function regexLiteralAround(literals: number[], cut: number): number { + let lo = 0; + let hi = literals.length / 2; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (literals[2 * mid]! < cut) lo = mid + 1; + else hi = mid; + } + return lo > 0 && literals[2 * lo - 1]! >= cut ? lo - 1 : -1; +} + +/** + * matchDestructuredCallResult for a call inside what the file's blanking read + * as a regex literal opening at `start`. Blanked on its own, the text above + * the call reads that `/` as division: it is the file's code up to there, + * then `tail` — the rest, blanked as text of its own. No binding statement + * runs across the `/` (literalDepths), so the call sees the file's bindings + * that end before it, then any in `tail`; and of the `depth` blocks open at + * the `/`, those `tail` does not close. + */ +function matchDestructuredCallInLiteral( + ref: UnresolvedRef, file: DestructuredFile, start: number, depth: number, cut: number, context: ResolutionContext +): ResolvedRef | null { + const tail = blankStringContents(file.code.slice(start, cut)); + const atCall = blocksThrough(tail, depth); + for (const m of Array.from(tail.matchAll(DESTRUCTURED_BINDING)).reverse()) { + const key = destructuredKeys(m[1]!).get(ref.referenceName); + if (!key) continue; + const atBinding = blocksThrough(tail.slice(0, m.index), depth); + const block = atBinding.opened[atBinding.opened.length - 1]; + if (block !== undefined ? !atCall.opened.includes(block) : atBinding.enclosing > atCall.enclosing) continue; + return destructuredCallTarget(m[2]!, key, tail.slice(m.index! + m[0].length), ref, context); + } + const bindings = file.byName.get(ref.referenceName) ?? []; + for (let i = bindings.length - 1; i >= 0; i--) { + const { binding, key } = bindings[i]!; + if (binding.end > start) continue; + if (binding.depth >= 0 && (binding.scopeEnd < start || binding.depth >= atCall.enclosing)) continue; + return destructuredCallTarget(binding.callee, key, file.code.slice(binding.end, start) + tail, ref, context); + } + return null; +} + +/** After `text`'s braces: how many of the `enclosing` blocks open before it are still open, and which it opened are. */ +function blocksThrough(text: string, enclosing: number): { enclosing: number; opened: number[] } { + const opened: number[] = []; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c === 123 /* { */) opened.push(i); + else if (c === 125 /* } */) { + if (opened.length > 0) opened.pop(); + else if (enclosing > 0) enclosing--; + } + } + return { enclosing, opened }; +} + +/** matchDestructuredCallResult by scanning the text above the call, where the whole file's reading may not hold. */ +function matchDestructuredCallAbove(ref: UnresolvedRef, source: string, context: ResolutionContext): ResolvedRef | null { const lines = source.split('\n'); const before = lines.slice(0, ref.line - 1).concat(lines[ref.line - 1]?.slice(0, ref.column) ?? '').join('\n'); const code = blankStringContents(stripCommentsForRegex(before, 'typescript')); - const binding = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*\(/g; const stackAt = (end: number): number[] => { const stack: number[] = []; for (let i = 0; i < end; i++) { @@ -9384,44 +9655,55 @@ function matchDestructuredCallResult(ref: UnresolvedRef, context: ResolutionCont return stack; }; const callScope = stackAt(code.length); - for (const m of [...code.matchAll(binding)].reverse()) { - let key: string | null = null; - for (const part of m[1]!.split(',')) { - const [k, v] = part.split(':').map((x) => x.trim().replace(/\s*=.*$/, '')); - if ((v ?? k) === ref.referenceName && /^[A-Za-z_$][\w$]*$/.test(k ?? '')) key = k!; - } + for (const m of [...code.matchAll(DESTRUCTURED_BINDING)].reverse()) { + const key = destructuredKeys(m[1]!).get(ref.referenceName); if (!key) continue; if (!stackAt(m.index!).every((pos, i) => callScope[i] === pos)) continue; - const rest = code.slice(m.index! + m[0].length); - if (new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(rest)) return null; - const calleeName = m[2]!; - const imported = context.resolveImport?.({ ...ref, referenceName: calleeName, referenceKind: 'calls' }); - // Through the import; else the same file's; else the one function of that - // name in the project (an alias the import resolver can't follow, like - // Nuxt's `~/composables/…`) — the returned key is checked below either way. - const holders = context.getNodesByName(calleeName).filter((n) => - (n.kind === 'function' || n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language)); - const callee = (imported && context.getNodeById?.(imported.targetNodeId)) ?? - holders.find((n) => n.filePath === ref.filePath) ?? - (holders.length === 1 ? holders[0] : undefined); - if (!callee || !sameLanguageFamily(callee.language, ref.language)) return null; - const calleeText = (context.getFileLines?.(callee.filePath) ?? context.readFile(callee.filePath)?.split('\n') ?? []) - .slice(callee.startLine - 1, callee.endLine).join('\n'); - if (!new RegExp(`\\breturn\\s*\\{[^]*?\\b${key}\\b`).test(calleeText)) return null; - const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'constant' || n.kind === 'variable'; - const inFile = context.getNodesInFile(callee.filePath); - const inner = inFile.filter((n) => n.name === key && callable(n) && n.id !== callee.id && rangeWithin(n, callee) && - !inFile.some((f) => f.id !== callee.id && f.id !== n.id && (f.kind === 'function' || f.kind === 'method') && - rangeWithin(f, callee) && rangeWithin(n, f) && !sameRange(f, n))); - const top = inner.length > 0 ? inner : inFile.filter((n) => n.name === key && callable(n) && !n.qualifiedName.includes('::') && - !inFile.some((f) => (f.kind === 'function' || f.kind === 'method') && f.id !== n.id && rangeWithin(n, f) && !sameRange(f, n))); - const target = top.sort((a, b) => Number(b.kind === 'function') - Number(a.kind === 'function'))[0]; - if (!target) return null; - return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'instance-method' }; + return destructuredCallTarget(m[2]!, key, code.slice(m.index! + m[0].length), ref, context); } return null; } +/** + * The function `calleeName` returns under `key`, unless a declaration in + * `rest` (the code between the binding and the call) shadows the binding. + * The callee is resolved first: most callees (`require`) return no such + * function, and then the shadowing scan is not needed. + */ +function destructuredCallTarget(calleeName: string, key: string, rest: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const target = destructuredKeyTarget(calleeName, key, ref, context); + if (!target) return null; + const name = ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(rest) ? null : target; +} + +/** The function `calleeName` returns under `key`: one declared in its body, else a top-level one of its module. */ +function destructuredKeyTarget(calleeName: string, key: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const imported = context.resolveImport?.({ ...ref, referenceName: calleeName, referenceKind: 'calls' }); + // Through the import; else the same file's; else the one function of that + // name in the project (an alias the import resolver can't follow, like + // Nuxt's `~/composables/…`) — the returned key is checked below either way. + const holders = context.getNodesByName(calleeName).filter((n) => + (n.kind === 'function' || n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language)); + const callee = (imported && context.getNodeById?.(imported.targetNodeId)) ?? + holders.find((n) => n.filePath === ref.filePath) ?? + (holders.length === 1 ? holders[0] : undefined); + if (!callee || !sameLanguageFamily(callee.language, ref.language)) return null; + const calleeText = (context.getFileLines?.(callee.filePath) ?? context.readFile(callee.filePath)?.split('\n') ?? []) + .slice(callee.startLine - 1, callee.endLine).join('\n'); + if (!new RegExp(`\\breturn\\s*\\{[^]*?\\b${key}\\b`).test(calleeText)) return null; + const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'constant' || n.kind === 'variable'; + const inFile = context.getNodesInFile(callee.filePath); + const inner = inFile.filter((n) => n.name === key && callable(n) && n.id !== callee.id && rangeWithin(n, callee) && + !inFile.some((f) => f.id !== callee.id && f.id !== n.id && (f.kind === 'function' || f.kind === 'method') && + rangeWithin(f, callee) && rangeWithin(n, f) && !sameRange(f, n))); + const top = inner.length > 0 ? inner : inFile.filter((n) => n.name === key && callable(n) && !n.qualifiedName.includes('::') && + !inFile.some((f) => (f.kind === 'function' || f.kind === 'method') && f.id !== n.id && rangeWithin(n, f) && !sameRange(f, n))); + const target = top.sort((a, b) => Number(b.kind === 'function') - Number(a.kind === 'function'))[0]; + if (!target) return null; + return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'instance-method' }; +} + /** Bound action names need not have a same-named definition (selectors may * rename them). The resolver's symbol-existence prefilter must allow them. */ export function matchJsStoreBindingCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { diff --git a/src/resolution/strip-comments.ts b/src/resolution/strip-comments.ts index b728e3722d..341cf7032e 100644 --- a/src/resolution/strip-comments.ts +++ b/src/resolution/strip-comments.ts @@ -27,8 +27,10 @@ * Blank string contents while preserving quotes and offsets. Template * interpolations are blanked too; callers checking executable expressions * must conservatively inspect those expressions in the original source. + * `regexLiterals`, when given, receives the offsets of the opening and + * closing `/` of every span read as a regex literal (left as is). */ -export function blankStringContents(text: string): string { +export function blankStringContents(text: string, regexLiterals?: number[]): string { const out = text.split(''); let i = 0; const n = text.length; @@ -45,7 +47,11 @@ export function blankStringContents(text: string): string { if (text[end] === ']') inClass = false; if (text[end] === '/' && !inClass) break; } - if (end < n && text[end] === '/') { i = end + 1; continue; } + if (end < n && text[end] === '/') { + regexLiterals?.push(i, end); + i = end + 1; + continue; + } } if (c === '"' || c === "'" || c === '`') { const quote = c;