diff --git a/CHANGELOG.md b/CHANGELOG.md index 7161cc2c..5f129ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,6 +259,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a type, class, constant or other declaration that a `.d.ts` file declares inside `declare module '…'` now links only from files that use that module: files that import it, the module's other declarations, and files that import it through a file of yours that re-exports it. Before, any file using the same name linked to it: Node's `import { readFileSync } from 'fs'` linked to the `fs` that VS Code's `vscode.d.ts` declares, and a `ProcessEnv`, or an LSP `TextDocument` imported through a barrel, to the `vscode` module's types of those names. Re-index TypeScript projects after upgrading. - In C++, a call written through a class the index doesn't hold, like protocolbuffers/protobuf's `FeatureSet::descriptor()` or `OneofOptions::default_instance()` (both classes are declared in `descriptor.pb.h`, a file too large to index), no longer links to another class's method of that name whose class only shares a word with it, like `JavaFeatures_NestInFileClassFeature::descriptor` or `CppFileOptions::default_instance`. A class named like the call's would have been found first, so one that only shares a word is another class. A call through a template parameter, through an alias, including one the calling function or a macro it expands declares, or through a namespace the file opens, aliases or uses links as before, and so does a call of a method only one class in the project declares. Re-index C++ projects after upgrading. - In C++, a call written through a namespace, like protocolbuffers/protobuf's `absl::StripPrefix(…)` or fmtlib/fmt's `fmt::ptr(p)`, no longer links to a class's method that only shares the function's name, like protobuf's `MultiCasePrefixStripper::StripPrefix` or a test scanner's `ptr()`: a namespace holds functions and types, not methods. A call through a namespace a macro opens now links to what that namespace declares, so fmt's `fmt::ptr(p)` reaches fmt's own `ptr` function and `fmt::day(…)` its `day` type, while a call into a library outside your project, like abseil, links to nothing. A call through a lowercase class, or an alias or typedef of one, links as before. Re-index C++ projects after upgrading. +- In TypeScript and JavaScript, a call to a function's own parameter, like `isAvailable(f)` on a parameter typed `(f: string) => boolean` or `compare(a, b)` on a comparator parameter, no longer links to a function of the same name in another file, or to the file's own import of that name. Before, a parameter whose type or default value contained parentheses, as a function type does, wasn't recognized as one. A parameter or local stands in for an import of its name only where it is in scope, so a Promise executor's `resolve(…)` no longer links to an imported `path.resolve`, while a `resolve(file)` after the executor still does. Re-index TypeScript and JavaScript projects after upgrading. +- In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/js-function-local-binding.test.ts b/__tests__/js-function-local-binding.test.ts index a0360929..710c2b8e 100644 --- a/__tests__/js-function-local-binding.test.ts +++ b/__tests__/js-function-local-binding.test.ts @@ -79,3 +79,228 @@ describe('JS names the calling function binds', () => { expect(targetsFrom('runInContext::run::walk')).toContain('runInContext::run::walk'); }); }); + +/** + * The same holds for a TypeScript parameter whose type or default holds + * parentheses, generics or braces, and across files: bitwarden's + * `isAvailable(f)` on a callback parameter typed `(f: string) => boolean` + * reached the napi `isAvailable` another file exports, and vscode's merge + * editor's `compare(a, b)` on a comparator parameter a `strings.ts` + * `compare`. A parameter shadows the file's own import of the name too, but + * only where it is in scope: inside its function's or arrow's body, never + * after it. Neither does `const syncState$` declare `syncState`, nor `const + * X = require('./x').default` hide the import it is. + */ +describe('TS parameters of any type are the calling function’s, in any file', () => { + let tsRoot = ''; + let ts: CodeGraph; + + beforeAll(async () => { + tsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-local-ts-')); + const files: Record = { + 'src/sdk.ts': 'export function isAvailable(format: string): boolean { return format.length > 0; }\n', + 'src/strings.ts': 'export function compare(a: number, b: number): number { return a - b; }\n', + 'src/use.ts': `import { isAvailable } from './sdk'; +export function check(format: string): boolean { + return isAvailable(format); +} +export function pick(formats: string[], isAvailable: (f: string) => boolean): string[] { + return formats.filter((f) => isAvailable(f)); +} +export function pickDefault(formats: string[], isAvailable = (f: string) => f.length > 0): string[] { + return formats.filter((f) => isAvailable(f)); +} +`, + 'src/sort.ts': `export const compareAll = (items: number[], compare: (a: number, b: number) => number) => + items.sort((a, b) => compare(a, b)); +export function later(cb: () => void, options: Map, compare: unknown): void { + cb(); + (compare as (a: number, b: number) => number)(options.size, 0); +} +export class Sorter { + constructor(private readonly compare: (a: number, b: number) => number) { + compare(1, 2); + } +} +`, + 'src/other.ts': `export function sortAll(items: number[]): number[] { + return items.sort((a, b) => compare(a, b)); +} +`, + 'src/state.ts': 'export function syncState(obs: object): object { return obs; }\n', + 'src/sync.ts': `import { syncState } from './state'; +export function syncObservable(obs$: object): object { + const syncState$ = syncState(obs$); + return syncState$; +} +export function syncLater(items: object[]): object { + items.forEach((syncState) => console.log(syncState)); + return syncState(items); +} +export function syncInside(items: object[]): void { + items.forEach((syncState: (o: object) => object) => { + syncState(items); + }); +} +`, + 'src/paths.ts': 'export function resolve(...parts: string[]): string { return parts.join("/"); }\n', + // No semicolons after the arrow in fmtAll, as a project formatted without them writes it. + 'src/run.ts': `import { resolve } from './paths'; +export function run(file: string): Promise { + return new Promise((resolve) => { + resolve(file); + }); +} +export function runThen(file: string): string { + new Promise((resolve) => setTimeout(resolve, 0)); + return resolve(file, 'out'); +} +export function fmtAll(items: string[]): string { + const fmt = (resolve: string) => resolve.trim() + return resolve(fmt(items[0])) +} +export function eachInLoop(files: string[]): void { + for (const resolve of [String]) resolve(files[0]); +} +export function eachAfterLoop(files: string[]): string { + for (const resolve of [String]) String(resolve); + return resolve(files[1]); +} +export function bare(): Promise { + return new Promise(resolve => { + resolve(); + }); +} +export function bareThen(file: string): string { + new Promise(resolve => setTimeout(resolve, 0)); + return resolve(file); +} +export function nested(file: string): string { + function resolve(p: string): string { return p; } + return resolve(file); +} +`, + 'src/widget.ts': 'export class Widget {}\nexport type Size = { width: number };\n', + // A return type's name is no lone arrow parameter, and a value never hides a type. + 'src/build.ts': `import { Widget, type Size } from './widget'; +export const buildWidget = (name: string): Widget => { + return new Widget(); +}; +export function measure(Size: number): number { + const size: Size = { width: Size }; + return size.width; +} +`, + // The object type a function returns is no body: withShape's own one still holds its parameter. + 'src/shape.ts': `import { compare } from './strings'; +export function withShape(compare: (a: number, b: number) => number): { sorted: number[]; size: number } { + return { sorted: [compare(1, 2)], size: 1 }; +} +export function plain(a: number, b: number): number { + return compare(a, b); +} +`, + 'src/biometrics.ts': `export default class OsBiometrics { + constructor(readonly name: string) {} +} +`, + // describeBiometrics binds the name too, so the file's bindings alone don't settle init's. + 'src/service.ts': `export class BiometricsService { + private service: object | undefined; + init(platform: string): void { + if (platform === 'darwin') { + const OsBiometrics = require('./biometrics').default; + this.service = new OsBiometrics('mac'); + } + } +} +export function describeBiometrics(OsBiometrics: unknown): string { + return String(OsBiometrics); +} +`, + // An anonymous IIFE's calls are the file's own: only the file's view of + // its bindings sees the parameter, after a function-typed one. + 'src/iife.ts': `((done: () => void, compare: (a: number, b: number) => number) => { + compare(1, 2); + done(); +})(() => {}, (a, b) => a - b); +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(tsRoot, rel)), { recursive: true }); + fs.writeFileSync(path.join(tsRoot, rel), content); + } + ts = await CodeGraph.init(tsRoot, { index: true }); + }); + + afterAll(() => { + ts?.close(); + if (tsRoot) fs.rmSync(tsRoot, { recursive: true, force: true }); + }); + + /** `:` of every call (or edge of `kind`) the named symbol in `file` makes. */ + const callsFrom = (file: string, name: string, kind = 'calls'): string[] => { + const node = ts.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file')!; + expect(node, `${file} ${name}`).toBeDefined(); + return ts.getOutgoingEdges(node.id).filter((e) => e.kind === kind).map((e) => { + const target = ts.getNode(e.target)!; + return `${target.filePath}:${target.qualifiedName}`; + }); + }; + + it('a parameter typed as a function, or defaulted to one, calls what it is given', () => { + expect(callsFrom('src/use.ts', 'pick')).toEqual([]); + expect(callsFrom('src/use.ts', 'pickDefault')).toEqual([]); + expect(callsFrom('src/sort.ts', 'compareAll')).toEqual([]); + }); + + it('after earlier parameters with function and generic types, and as a parameter property', () => { + expect(callsFrom('src/sort.ts', 'later')).toEqual([]); + expect(callsFrom('src/sort.ts', 'constructor')).toEqual([]); + }); + + it('shadows the file’s import of the name, which a function binding nothing still calls', () => { + expect(callsFrom('src/use.ts', 'check')).toEqual(['src/sdk.ts:isAvailable']); + }); + + it('in code no function node holds, the parameter list the call sits in', () => { + const file = ts.getNodesInFile('src/iife.ts').find((n) => n.kind === 'file')!; + const calls = ts.getOutgoingEdges(file.id).filter((e) => e.kind === 'calls').map((e) => ts.getNode(e.target)!.filePath); + expect(calls).not.toContain('src/strings.ts'); + }); + + it('leaves a name no function binds to the matcher', () => { + expect(callsFrom('src/other.ts', 'sortAll')).toEqual(['src/strings.ts:compare']); + }); + + it('shadows an import only inside the body that binds the name', () => { + expect(callsFrom('src/sync.ts', 'syncInside')).toEqual([]); + expect(callsFrom('src/run.ts', 'run')).toEqual([]); + expect(callsFrom('src/run.ts', 'eachInLoop')).toEqual([]); + expect(callsFrom('src/sync.ts', 'syncLater')).toEqual(['src/state.ts:syncState']); + expect(callsFrom('src/run.ts', 'runThen')).toEqual(['src/paths.ts:resolve']); + expect(callsFrom('src/run.ts', 'eachAfterLoop')).toEqual(['src/paths.ts:resolve']); + }); + + it('ends an arrow’s expression body at a line that starts the next statement', () => { + expect(callsFrom('src/run.ts', 'fmtAll')).toContain('src/paths.ts:resolve'); + }); + + it('reads a lone arrow parameter, a nested function and a body after an object return type', () => { + expect(callsFrom('src/run.ts', 'bare')).toEqual([]); + expect(callsFrom('src/run.ts', 'bareThen')).toEqual(['src/paths.ts:resolve']); + expect(callsFrom('src/run.ts', 'nested')).not.toContain('src/paths.ts:resolve'); + expect(callsFrom('src/shape.ts', 'withShape')).toEqual([]); + expect(callsFrom('src/shape.ts', 'plain')).toEqual(['src/strings.ts:compare']); + }); + + it('takes no return type for a parameter, and lets no value hide a type', () => { + expect(callsFrom('src/build.ts', 'buildWidget', 'instantiates')).toEqual(['src/widget.ts:Widget']); + expect(callsFrom('src/build.ts', 'measure', 'references')).toContain('src/widget.ts:Size'); + }); + + it('reads `syncState$` as no declaration of `syncState`, and a required local as the import', () => { + expect(callsFrom('src/sync.ts', 'syncObservable')).toEqual(['src/state.ts:syncState']); + expect(callsFrom('src/service.ts', 'init', 'instantiates')).toEqual(['src/biometrics.ts:OsBiometrics']); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 31c8f803..c0f9e5c6 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1356,6 +1356,10 @@ export class ReferenceResolver { const module = this.nodeById(resolved.targetNodeId); if (module && module.kind !== 'file' && module.kind !== 'module') return null; } + // A name the calling JS/TS function binds itself (a parameter, a local) in + // scope at the reference is that binding even when the file imports the + // name, which the import resolver answers before the matcher's own check. + if (resolved && resolved.resolvedBy === 'import' && isImportShadowedByJsBinding(resolved, ref, this.context)) return null; // A declaration of a declaration file's `declare module 'x'` is reached // through an import of `x`, never by its bare name from a file that // reaches no `x` (see reachesAmbientModule). diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6640fa73..8d4f48db 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -11136,29 +11136,171 @@ export function jsCodeBindsName(name: string, fn: Node, filePath: string, line: let binds = memo.get(key); if (binds === undefined) { const text = jsFunctionCodeThrough(fn, filePath, line, context); - const { param } = localBindingPatterns(name, 'g'); const n = name.replace(/\$/g, '\\$'); // A plain declaration. Destructuring re-binds what a call returns under // the same name — `const { t } = useI18n()`, `const { getLabel } = // useProps(props)` — which is the same-named function more often than not. - const declared = new RegExp(`\\b(?:const|let|var)\\s+${n}\\b(?!\\s*[,\\]}])`).test(text); - // 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)`. - // The leading `(?=\()` (every match opens a list) keeps the lookbehind to - // where one opens. Node 22's V8 stops optimizing the regexes a process - // compiles once it has generated about a megabyte of regex code, which a - // resolver pool worker soon has, and then tried the lookbehind at every - // position, each time reading back through the run of blanks before it: - // go-ethereum's graphiql.min.js, a 980 KB line whose last 962 KB the - // stripper blanks (it reads the `//` closing `/Trident\//` as a comment), - // never resolved. - const parameter = new RegExp(`(?=\\()(? returnType)}`, flags); +} + +/** + * Whether the JS/TS function a reference sits in binds its name in a scope + * that holds at the reference, not just somewhere in its text: a parameter + * list or a lone arrow parameter whose body is still open there (a + * function's or a `catch` clause's block, an arrow's block or expression), + * or a `var`/`let`/`const`, `function` or `class` whose block or `for` + * statement is. A `new Promise((resolve) => …)` executor's `resolve` is the + * calls inside it, not a `resolve(file)` after it, and a `const X = + * require('./x')` binds the import itself. A function too long to read + * again keeps the import. + */ +function jsBindingEnclosesReference(name: string, fn: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + const through = jsFunctionCodeThrough(fn, ref.filePath, ref.line, context); + const cut = through.slice(0, through.lastIndexOf('\n') + 1 + ref.column); + if (cut.length > 100_000) return false; + // Brackets in strings, template literals and regex literals open no scope. + const regexes: number[] = []; + const chars = blankStringContents(cut, 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 n = name.replace(/\$/g, '\\$'); + const declaration = new RegExp( + `\\b(?:(?:const|let|var)\\s+${n}(?![\\w$])(?!\\s*[,\\]}])(\\s*=\\s*(?:await\\s+)?(?:require|import)\\s*\\()?|(?:function|class)\\s+${n}(?![\\w$]))`, + 'g' + ); + for (const m of code.matchAll(declaration)) { + if (m[1]) continue; + const inForHead = /\bfor\s*(?:await\s*)?\(\s*$/.test(code.slice(Math.max(0, m.index! - 16), m.index)); + if (inForHead ? jsForStatementOpenAtEnd(code, m.index!) : jsBlockOpenAtEnd(code, m.index!)) return true; + } + for (const m of code.matchAll(jsParameterListPattern(name, 'g'))) { + const end = m.index! + m[0].length; + if (code[end - 1] === '{' ? jsBlockOpenAtEnd(code, end) : jsBodyOpenAtEnd(code, end)) return true; + } + // `new Promise(resolve => { … })`: a lone parameter where an + // expression starts, never the name a return type ends with (`(): Bounds =>`). + const loneParameter = new RegExp(`(^|[(,=\\[{?:]|=>|\\b(?:return|async|default|yield|await))\\s*${n}\\s*=>`, 'g'); + for (const m of code.matchAll(loneParameter)) { + if (m[1] === ':' && /\)\s*$/.test(code.slice(Math.max(0, m.index! - 200), m.index))) continue; + if (jsBodyOpenAtEnd(code, m.index! + m[0].length)) return true; + } + return false; +} + +/** + * Whether the block that holds `at` (just inside a body's `{`, or where a + * declaration starts) is still open at the end of `code`. + */ +function jsBlockOpenAtEnd(code: string, at: number): boolean { + let depth = 0; + for (let i = at; i < code.length; i++) { + if (code[i] === '{') depth++; + else if (code[i] === '}' && --depth < 0) return false; + } + return true; +} + +const JS_OPERAND_END = /[\w$)\]}'"`]/; +const JS_OPERAND_START = /[\w$'"`]/; + +/** + * Whether the body that starts at `at` (an arrow's, after its `=>`, or a + * `for` statement's, after its head) is still open at the end of `code`: a + * `{…}` block, or an expression or statement, which a comma or a semicolon + * ends, or the bracket closing around it, or a line that starts another + * statement where no semicolon ends the last. + */ +function jsBodyOpenAtEnd(code: string, at: number): boolean { + let i = at; + while (i < code.length && WHITESPACE.test(code[i]!)) i++; + if (code[i] === '{') return jsBlockOpenAtEnd(code, i + 1); + let depth = 0; + for (; i < code.length; i++) { + const c = code[i]!; + if (c === '(' || c === '[' || c === '{') { + depth++; + } else if (c === ')' || c === ']' || c === '}') { + if (--depth < 0) return false; + } else if (depth === 0 && (c === ',' || c === ';')) { + return false; + } else if (depth === 0 && c === '\n') { + // `const f = (x) => x + 1` with no semicolon, above the reference: the + // line before ends an operand (a name, a closing bracket, a quote) and + // the next starts one (the reference itself, when the code ends there) + // rather than an operator or a `.` continuing the expression. + let before = i - 1; + while (before >= 0 && WHITESPACE.test(code[before]!)) before--; + let after = i + 1; + while (after < code.length && WHITESPACE.test(code[after]!)) after++; + if (JS_OPERAND_END.test(code[before] ?? '') && (after === code.length || JS_OPERAND_START.test(code[after]!))) return false; + i = after - 1; + } + } + return true; +} + +/** + * Whether a declaration in a `for` head, at `at`, is still in scope at the + * end of `code`: in the head, or in the statement after it. + */ +function jsForStatementOpenAtEnd(code: string, at: number): boolean { + 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 === ')' && jsBodyOpenAtEnd(code, i + 1); + } + return true; +} + +/** + * Whether an import-resolved `result` is the file's import of the reference's + * name while the calling JS/TS function binds that name itself, in a scope + * that holds at the reference (jsBindingEnclosesReference): bitwarden's + * `pick(…, isAvailable: (f: string) => boolean)` calling `isAvailable(f)` + * means its parameter, not the file's `import { isAvailable } from './sdk'`. + * The import resolver answers before matchReference's check, which reads the + * function's whole text — too coarse for an import, whose link is usually right. + */ +export function isImportShadowedByJsBinding(result: ResolvedRef, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (!JS_FAMILY.has(ref.language) || !JS_LOCAL_REF_KINDS.has(ref.referenceKind) || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return false; + const target = context.getNodeById?.(result.targetNodeId); + // (A parameter or local is a value: it hides no interface or type alias, `let coords: Bounds`.) + if (!target || target.name !== ref.referenceName || target.kind === 'interface' || target.kind === 'type_alias') return false; + const fn = context.getNodeById?.(ref.fromNodeId); + if (!fn || (fn.kind !== 'function' && fn.kind !== 'method') || fn.startLine > ref.line || fn.endLine < ref.line) return false; + if (target.filePath === ref.filePath && target.startLine >= fn.startLine && target.startLine <= fn.endLine) return false; + // No function binds a name its file never does (read once per file and name). + if (!isLocallyBoundJsName(ref.referenceName, ref.filePath, context)) return false; + if (ref.referenceKind === 'calls' && bareCallReceiver(ref, context) !== null) return false; + return jsBindingEnclosesReference(ref.referenceName, fn, ref, context); +} + /** * Per context: the comment-stripped lines of the functions jsCodeBindsName * read last, and where each line ends in that text, most recent last. @@ -11241,28 +11383,50 @@ const JS_BINDING_NAME = /^[\w$]+$/; const ARROW_HEAD_CHAR = /[\w$.]/; const IMPORT_BINDING_VALUE = /^\s*(?:await\s+)?(?:require|import)\s*\(/; +// The balanced groups a parameter's type or default can hold, two levels deep. +const PARAM_PAREN = '\\((?:[^()]|\\([^()]*\\))*\\)'; +const PARAM_BRACE = '\\{(?:[^{}]|\\{[^{}]*\\})*\\}'; +const PARAM_BRACKET = '\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\]'; +const PARAM_ANGLE = '<(?!\\s)(?:=>|=(?!>)|[^<>=]|<(?:=>|=(?!>)|[^<>=])*>)*>'; +/** + * One piece of a parameter's type or default: a character other than a comma, + * or a balanced group, which may hold commas (`Map`, `{ a: A, b: B }`, + * `(a: number, b: number) => number`). Every alternative starts with a + * character of its own — `=>` is the arrow of a function type, and a `<` or + * `>` spaced like an operator (`f.length > 0`) is no bracket — so a type has + * one parse. + */ +const PARAM_PART = `(?:=>|=(?!>)|[^,(){}\\[\\]<>=]|<(?=\\s)|>(?=[\\s=])|${PARAM_PAREN}|${PARAM_BRACE}|${PARAM_BRACKET}|${PARAM_ANGLE})`; +/** A TypeScript parameter property's modifiers: `constructor(private readonly store: Store)`. */ +const PARAM_MODIFIERS = '(?:(?:public|private|protected|readonly|override)\\s+)*'; + function localBindingPatterns(name: string, flags: string): LocalBindingPatterns { const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return { // `const { name } = require('./m')` / `= await import('./m')` binds an IMPORT, // not a shadow: the symbol lives in the other file and the call means it. decl: new RegExp( - '\\b(?:const|let|var)\\s+(?:' + n + '\\b|[{\\[][^;=]*?\\b' + n + '\\b[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?', + '\\b(?:const|let|var)\\s+(?:' + n + '(?![\\w$])|[{\\[][^;=]*?\\b' + n + '(?![\\w$])[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?', flags ), - fn: new RegExp('\\b(?:function|class)\\s+' + n + '\\b', flags), + fn: new RegExp('\\b(?:function|class)\\s+' + n + '(?![\\w$])', flags), // a parameter: every token before the name in the list is itself a - // parameter (identifier, optional type, optional default) — so a string - // argument containing the word cannot match. + // parameter (identifier or destructuring, optional type, optional + // default) — so a string argument containing the word cannot match. // Each earlier parameter has exactly one parse: its first non-space // character after the identifier picks the type (`?`/`:`), default (`=`) - // or bare alternative. Written as `(type)?(default)?\s*`, the same - // strings split several ways per parameter, and a failing search - // backtracked through every combination — 30-40s per name on a vscode - // test file whose helper takes nine `name: T = value` parameters. + // or bare alternative, and a type or default runs to the comma. Written + // as `(type)?(default)?\s*`, the same strings split several ways per + // parameter, and a failing search backtracked through every combination + // — 30-40s per name on a vscode test file whose helper takes nine `name: + // T = value` parameters. A type or default may hold balanced groups + // (PARAM_PART): `isAvailable: (f: string) => boolean`, `cb: () => void` + // before the name, `compare = (a, b) => a - b`. param: new RegExp( - '\\(\\s*(?:(?:\\.\\.\\.)?[\\w$]+(?:\\s*(?:\\?\\s*)?:[^,()]+|\\s*=[^,()]+|\\s*),\\s*)*' + - n + '\\b(?:\\s*\\??\\s*:[^,()]*)?(?:\\s*=[^,()]*)?(?:\\s*,\\s*[^()]*)?\\)\\s*(?::[^=;{]*)?(?:=>|\\{)', + '\\(\\s*(?:(?:' + PARAM_MODIFIERS + '(?:\\.\\.\\.)?[\\w$]+|' + PARAM_BRACE + '|' + PARAM_BRACKET + ')' + + '(?:\\s*(?:\\?\\s*)?:' + PARAM_PART + '+|\\s*=' + PARAM_PART + '+|\\s*),\\s*)*' + + PARAM_MODIFIERS + n + '(?![\\w$])(?:\\s*\\??\\s*:' + PARAM_PART + '*)?(?:\\s*=' + PARAM_PART + '*)?' + + '(?:\\s*,(?:[^()]|' + PARAM_PAREN + ')*)?\\)\\s*(?::[^=;{]*)?(?:=>|\\{)', flags ), }; @@ -11308,12 +11472,31 @@ function bindsAnywhere(source: string, name: string): boolean { return fn.test(source) || param.test(source) || new RegExp('(?:^|[^\\w$.])' + n + '\\s*=>').test(source); } +/** + * The `(` of the parenthesized list `source[at]` sits in, read back over the + * balanced groups before it (an earlier parameter's `cb: () => void` or `{ a, + * b }`), or -1 when the name sits in a block, an object or an array, after a + * statement's end, or too far back to be in a parameter list. + */ +function enclosingOpenParen(source: string, at: number): number { + let depth = 0; + for (let i = at - 1; i >= 0 && at - i <= 2000; i--) { + const c = source[i]; + if (c === ')' || c === ']' || c === '}') depth++; + else if (c === '(' || c === '[' || c === '{') { + if (depth === 0) return c === '(' ? i : -1; + depth--; + } else if (c === ';' && depth === 0) return -1; + } + return -1; +} + /** * bindsAnywhere for a plain identifier, tried only where a match can start: * a declaration at its keyword (in order, resuming past each match exactly as - * the global search does), a parameter at the `(` its list opens with — the - * last `(` before an occurrence of the name, with no `)` between, since no - * earlier parameter can hold a parenthesis — and `name =>` at each arrow. + * the global search does), a parameter at the `(` its list opens with + * (enclosingOpenParen, from an occurrence of the name), and `name =>` at each + * arrow. */ function bindsAtSites(source: string, name: string, sites: LocalBindingSites): boolean { let patterns = LOCAL_BINDING_PATTERNS.get(name); @@ -11340,8 +11523,8 @@ function bindsAtSites(source: string, name: string, sites: LocalBindingSites): b } let tried = -1; for (let at = source.indexOf(name); at !== -1; at = source.indexOf(name, at + 1)) { - const open = at > 0 ? source.lastIndexOf('(', at - 1) : -1; - if (open < 0 || open === tried || source.lastIndexOf(')', at - 1) > open) continue; + const open = enclosingOpenParen(source, at); + if (open < 0 || open === tried) continue; tried = open; param.lastIndex = open; if (param.test(source)) return true;