/** * Name Matcher * * Handles symbol name matching for reference resolution. */ import * as fs from 'fs'; import * as path from 'path'; import { Language, Node, IMPLICIT_RECORD_ACCESSOR_DOC } from '../types'; import { escapeRegExp, escapesRoot } from '../utils'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { isJsSuperCallSite } from './js-super-calls'; import { JS_ARRAY_METHODS, JS_BROWSER_HOST_GLOBALS, JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-visibility'; import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, dartPrivateVisible, inSameDartLibrary } from './dart-libraries'; import { isDartLocallyBound } from './dart-local-scope'; import { breakVbTie, isVbMemberInScope, isVbNestedTypeInScope, isVbTypeQualifiedBy, matchVbTypedCall, preferVbProject, sameVbProject } from './vbnet-receivers'; import { cppAliasedClass, cppAliasedTypeName, cppAliasedTypeWritten, cppDeclaresLocalAlias, cppParentScope, cppScopesWithin, cppTemplateArguments, cppTemplateParameters, cppTypeNamesDeclared, cppTypeSegments, isCppPointerType, resolveCppAliasedType, stripCppTemplateArguments, type CppAliasedType } from './cpp-type-aliases'; import { clearCppNamespaceMemos, cppMacroNamespaceFrames, cppMacroNamespaceHeads, cppNameMacros, cppNamespaceAliases } from './cpp-namespaces'; import { clearCppRawStringMemos, cppLiteralsBlanked, cppNonCodeLines } from './cpp-raw-strings'; import { cppClassWritten, type CppNameSite } from './cpp-supertypes'; import { cppIncludedFile, cppIncludedHeadersBeside, cppIncluders } from './cpp-includers'; import { isCFeatureVariant, isOffByDefault } from './c-feature-variants'; import { isTestFile, isTestPath } from '../search/query-utils'; import { inBlock, isDeclarationFile, isMergedAmbientDeclaration, scanGlobalScope, type GlobalScopeScan } from './ambient-modules'; import { isMinifiedContent } from '../extraction/generated-detection'; import { getCargoWorkspaceCrateMap, parsePackageName, parseTargetPaths } from './frameworks/cargo-workspace'; import { blankRustCode, readRustScopes, rustBindingAt, rustInnerBlockAt, rustBoundsOf, rustFnAt, rustImplAt, splitRustTopLevel, type RustBinding, type RustPatternSite, type RustScopes } from './rust-scopes'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will * score. A name defined more times than this is "ubiquitous" — a method/symbol * re-declared across a vendored theme or SDK (e.g. `init`/`update`/`render` on * every widget of a committed Metronic theme — #999). No directory-proximity or * receiver-word-overlap score can reliably pick THE one true target among * thousands, so the fuzzy strategies (matchByExactName's findBestMatch, and * matchMethodCall Strategy 3) decline above the ceiling instead of emitting a * low-confidence, almost-certainly-wrong edge. This also caps their per-ref cost * at O(ceiling): without it, K same-named refs each scored K candidates — the * O(K²) blow-up that pinned a core for 15-28 min at "Resolving refs … 94%" on a * repo vendoring a large JS/TS theme (#999). The PRECISE strategies are * unaffected: qualified-name, import-based, and class-name (Strategy 1/2) * resolution all still run and resolve a ubiquitous name when the context names * its exact target. Real repos top out near ~40 same-named methods, so a normal * codebase never reaches this; only bulk-vendored code does. Tune via * `CODEGRAPH_AMBIGUOUS_NAME_CEILING`. */ const DEFAULT_AMBIGUOUS_NAME_CEILING = 500; function resolveAmbiguousNameCeiling(): number { const raw = process.env.CODEGRAPH_AMBIGUOUS_NAME_CEILING; if (!raw) return DEFAULT_AMBIGUOUS_NAME_CEILING; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AMBIGUOUS_NAME_CEILING; } const AMBIGUOUS_NAME_CEILING = resolveAmbiguousNameCeiling(); /** * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid") * by matching the filename against file nodes. */ export function matchByFilePath( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { // Path-like (`a/b.liquid`) OR a bare filename ending in a short extension // (`Foo.h` — an Objective-C `#import "Foo.h"`, resolved to the header by // basename). A bare ref WITHOUT an extension is a symbol name, not a file, so // leave it to the symbol-matching strategies. if (!ref.referenceName.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(ref.referenceName)) { return null; } // Extract the filename from the path const fileName = ref.referenceName.split('/').pop(); if (!fileName) return null; // Search for file nodes with this name const candidates = context.getNodesByName(fileName); const fileNodes = candidates.filter(n => n.kind === 'file'); if (fileNodes.length === 0) return null; // Prefer exact path match on qualified_name const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName); if (exactMatch) { return { original: ref, targetNodeId: exactMatch.id, confidence: 0.95, resolvedBy: 'file-path', }; } // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches // "src/snippets/foo.liquid"). When several files share the basename — a // `#include "RNCAsyncStorage.h"` with a same-named header on another platform // (windows/code/ vs apple/) — prefer the one in the includer's own directory, // then by directory proximity / same language family. A C/C++ include (and any // bare-filename import) resolves relative to the including file, not to an // arbitrary same-named header elsewhere in the tree. const suffixMatches = fileNodes.filter( n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName) ); if (suffixMatches.length > 0) { return { original: ref, targetNodeId: pickClosestFileNode(suffixMatches, ref).id, confidence: 0.85, resolvedBy: 'file-path', }; } // If only one file node with this name, use it with lower confidence if (fileNodes.length === 1) { return { original: ref, targetNodeId: fileNodes[0]!.id, confidence: 0.7, resolvedBy: 'file-path', }; } return null; } /** * Among several file nodes that all match a bare include/import by basename, * pick the one closest to the referencing file: same directory first, then by * directory-tree proximity, with the same language family as a tiebreak. A * C/C++ `#include "X.h"` (and any bare-filename import) resolves relative to the * including file — not to an arbitrary same-named header on another platform. */ function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node { const dirOf = (p: string): string => { const i = p.lastIndexOf('/'); return i >= 0 ? p.slice(0, i) : ''; }; const refDir = dirOf(ref.filePath); const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir); const pool = sameDir.length > 0 ? sameDir : candidates; let best = pool[0]!; let bestScore = -Infinity; for (const c of pool) { const score = computePathProximity(ref.filePath, c.filePath) + (sameLanguageFamily(c.language, ref.language) ? 5 : 0); if (score > bestScore) { bestScore = score; best = c; } } return best; } /** * Language families that share a type system / runtime, so a same-language-only * reference may still resolve across them (a Kotlin `Foo.BAR` can name a Java * `Foo`). Anything not listed forms its own singleton family. */ const LANGUAGE_FAMILY: Record = { java: 'jvm', kotlin: 'jvm', scala: 'jvm', swift: 'native', objc: 'native', // ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with // `.ts` logic modules, so refs must cross freely between them. typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web', c: 'native', cpp: 'native', // Razor/Blazor markup names C# types — same family so `@model Foo` / // `` resolve to their `.cs` class through the cross-family gate. csharp: 'dotnet', razor: 'dotnet', vbnet: 'dotnet', svelte: 'web', vue: 'web', astro: 'web', cfml: 'cfml', cfscript: 'cfml', }; export function sameLanguageFamily(a: string, b: string): boolean { if (a === b) return true; const fa = LANGUAGE_FAMILY[a]; return fa !== undefined && fa === LANGUAGE_FAMILY[b]; } /** * The languages whose files share one table of names: a C function is called * by its bare name from C++ and Objective-C alike, and a `.h` file is C, C++ * or Objective-C only as far as sniffing its text can tell (Dear ImGui's * C++ `imgui.h` reads as C), so name matching ranks them as one language. * Other families keep the language itself: their files reach each other's * names through imports, and a name-only pick across TypeScript and TSX, or * Java and Kotlin, went to a same-named declaration in the next file more * often than to the one meant (#1662). */ const C_LINKED_LANGUAGES: ReadonlySet = new Set(['c', 'cpp', 'objc']); function sameRankingLanguage(a: string, b: string): boolean { return a === b || (C_LINKED_LANGUAGES.has(a) && C_LINKED_LANGUAGES.has(b)); } /** Config/markup transitions stay open; every other code language has a family. */ const CODE_FAMILY: Record = { ...LANGUAGE_FAMILY, python: 'python', go: 'go', rust: 'rust', php: 'php', ruby: 'ruby', dart: 'dart', lua: 'lua', luau: 'lua', r: 'r', erlang: 'erlang', pascal: 'pascal', solidity: 'solidity', nix: 'nix', cobol: 'cobol', }; export function crossesCodeBoundary(a: string, b: string): boolean { return CODE_FAMILY[a] !== undefined && CODE_FAMILY[b] !== undefined && CODE_FAMILY[a] !== CODE_FAMILY[b]; } /** * Cross-family name matches need a framework export or an actual ABI boundary, * not merely a native caller. ABI evidence is scoped to the named free function. */ function hasBridgeEvidence(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { if (ref.referenceKind !== 'calls') return false; // Expo's extractor creates explicit JS exports, resolved by the ordinary // name matcher rather than a framework resolve() branch. if (CODE_FAMILY[ref.language] === 'web' && candidate.id.startsWith('expo-module:') && candidate.isExported && (candidate.language === 'swift' || candidate.language === 'kotlin')) return true; if (candidate.kind !== 'function') return false; const name = candidate.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (CODE_FAMILY[ref.language] === 'native') { const source = context.readFile(candidate.filePath); if (!source) return false; if (candidate.language === 'go') { const declaration = source.split('\n').slice(Math.max(0, candidate.startLine - 2), candidate.endLine).join('\n'); return /\bimport\s+(?:\(\s*)?"C"/.test(stripCommentsForRegex(source, 'go')) && new RegExp('^//export ' + name + '\\r?\\nfunc ' + name + '\\s*\\(', 'm').test(declaration); } if (candidate.language === 'rust') { const declaration = source.split('\n').slice(candidate.startLine - 1, candidate.endLine).join('\n'); return new RegExp('\\bpub\\s+extern\\s+"C"\\s+fn\\s+' + name + '\\b') .test(stripCommentsForRegex(declaration, 'rust')); } } if (candidate.language === 'c' || candidate.language === 'cpp') { const source = context.readFile(ref.filePath); if (!source) return false; if (ref.language === 'go') { return /\bimport\s+(?:\(\s*)?"C"/.test(stripCommentsForRegex(source, 'go')) && ref.referenceName === 'C.' + candidate.name; } if (ref.language === 'rust') { return new RegExp('extern\\s+"C"\\s*\\{[^}]*\\bfn\\s+' + name + '\\s*\\(') .test(stripCommentsForRegex(source, 'rust')); } } return false; } /** * Per-context memo: node id → its language, for gateLanguageMatch. Matches * land on ~5 refs per target on vscode, and each check otherwise fetched the * whole node (a point read + row decode past the query layer's small cache) * only to read one field. Nodes are fixed within a resolution pass; the memo * drops with clearNameMatcherMemos. */ const TARGET_LANGUAGE = new WeakMap>(); /** Reject the chosen result without shrinking a pool or trying a replacement. */ export function gateLanguageMatch( result: ResolvedRef | null, ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { if (!result) return result; // No code family on the reference's side: no target can cross a boundary. if (CODE_FAMILY[ref.language] === undefined) return result; if (context.getNodeById) { let languages = TARGET_LANGUAGE.get(context); if (!languages) { languages = new Map(); TARGET_LANGUAGE.set(context, languages); } let language = languages.get(result.targetNodeId); if (language === undefined) { const node = context.getNodeById(result.targetNodeId); if (node) { language = node.language as string; if (languages.size >= 400_000) languages.clear(); languages.set(result.targetNodeId, language); } } if (language !== undefined) { if (!crossesCodeBoundary(ref.language, language)) return result; const target = context.getNodeById(result.targetNodeId); return target && !hasBridgeEvidence(target, ref, context) ? null : result; } } const target = context.getNodeById?.(result.targetNodeId) ?? context.getNodesByName(ref.referenceName).find((n) => n.id === result.targetNodeId); if (target && crossesCodeBoundary(ref.language, target.language) && !hasBridgeEvidence(target, ref, context)) return null; return result; } /** Member values retain their receiver; never break ties by file order (#1820). */ function matchMemberFunctionRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { const dot = ref.referenceName.lastIndexOf('.'); const receiver = ref.referenceName.slice(0, dot); const member = ref.referenceName.slice(dot + 1); const result = (nodes: Node[], confidence = 0.9): ResolvedRef | null => { const pool = nodes.filter(n => sameLanguageFamily(n.language, ref.language)); const target = pool.length === 1 ? pool[0] : undefined; return target && (target.kind === 'function' || target.kind === 'method') && target.id !== ref.fromNodeId && !isPythonProperty(target, context) ? { original: ref, targetNodeId: target.id, confidence, resolvedBy: 'function-ref' } : null; }; const imports = context.getImportMappings(ref.filePath, ref.language); // An import is authoritative even when it points outside the project. if (imports.some(i => i.localName === receiver.split('.')[0])) { if (ref.language === 'python') { const cls = pythonRefClass(receiver, ref, context); if (cls) return result(pythonMembers(cls, member, ref, context)); // `mod.global` / an imported `global` itself — never a deeper chain (`mod.global.field`). const segments = receiver.split('.'); const hit = segments.length <= 2 ? context.resolveImport?.({ ...ref, referenceName: receiver, referenceKind: 'references' }) : null; const global = hit && context.getNodeById?.(hit.targetNodeId); // A local import of the root binds what the file imports, unless the file imports it from two places. if (global && global.name === segments[segments.length - 1] && isPythonModuleGlobal(global, context) && pythonImportKeys(segments[0]!, ref.filePath, context).size === 1 && !pythonGlobalBindings(segments[0]!, ref.filePath, context).some(b => b.kind !== 'import') && !pythonBindsLocally(segments[0]!, ref, context, false)) { return result(pythonGlobalMembers(global, member, ref, context)); } } const imported = context.resolveImport?.(ref); const node = imported && context.getNodeById?.(imported.targetNodeId); return node ? result(context.getNodesByQualifiedName(node.qualifiedName).filter(n => n.filePath === node.filePath)) : null; } if (ref.language === 'go') { if (receiver.includes('.')) return matchGoFieldChainCall(receiver, member, ref, context); const decl: GoReceiverDecl = {}; const type = inferLocalReceiverType(receiver, ref, context, decl); if (decl.asserted !== undefined) { return decl.asserted && resolveMethodOnType(decl.asserted.name, member, ref, context, 0.9, 'function-ref', decl.asserted.dir); } // One declared as a type literal, a type from outside the project or an // alias of one holds no project method to take. if (decl.declared === null || (decl.declared !== undefined && goHasNoProjectMethods(decl.declared, ref, context))) return null; // Nor one a call or a conversion gives a value of such a type. if (decl.valued === null || (decl.valued && goValuedHasNoProjectMethods(decl.valued, context))) return null; if (type) { return resolveMethodOnType(type, member, ref, context, 0.9, 'function-ref', goDeclaredTypePackage(decl.raw, decl.file ?? ref.filePath, context)); } const types = context.getNodesByName(receiver).filter(n => n.language === 'go' && (n.kind === 'struct' || n.kind === 'interface')); if (types.length) return types.length === 1 ? resolveMethodOnType(receiver, member, ref, context, 0.9, 'function-ref') : null; } else { const owner = context.getNodesInFile(ref.filePath).filter(n => n.kind === 'class' && n.startLine <= ref.line && n.endLine >= ref.line) .sort((a, b) => b.startLine - a.startLine)[0]; let type: string | null = null; if (receiver === 'self' || receiver === 'cls') { return owner ? result(pythonMembers(owner, member, ref, context)) : null; } if (/^(self|cls)\.\w+$/.test(receiver)) { if (!owner) return null; type = pythonFieldType(receiver, owner, ref, context); } else { type = pythonLocalType(receiver, ref, context); const viaGlobal = type === null ? pythonGlobalReceiverTarget(receiver, member, ref, context) : undefined; if (viaGlobal !== undefined) { return viaGlobal ? { original: ref, targetNodeId: viaGlobal.id, confidence: 0.9, resolvedBy: 'function-ref' } : null; } } // A type name used directly (`Store.fetch`) is scoped just like an annotation. if (!type && /^[A-Z]\w*$/.test(receiver)) type = receiver; if (type && type !== 'object' && type !== 'Any') { const cls = pythonRefClass(type, ref, context); if (!cls) return null; const members = pythonMembers(cls, member, ref, context); if (members.length) return result(members); // A base-typed field can hold a subclass-only method (the reported case). // Keep only descendants of THAT base; unrelated same-name methods cannot win. const candidates = context.getNodesByName(member).filter(n => n.kind === 'method' && n.language === 'python'); const descendants = candidates.filter(n => { const parent = context.getNodesInFile(n.filePath).find(c => c.kind === 'class' && n.qualifiedName === `${c.qualifiedName}::${member}`); return parent && pythonDerivesFrom(parent, cls, ref, context); }); return result(descendants, 0.8); } } // Unknown receivers retain the old unique-or-drop discipline, across ALL // files. Tests and abstract-looking bodies are candidates too. A lone method // stands only when the receiver is named after its owner: netbox's // `device=self.parent.device` is a model field, not the project's one // `device` method (a GraphQL filter's). A veto, never a filter — filtering // first would promote some other lone match into a new guess. const unique = result(context.getNodesByName(member), 0.8); const target = unique ? context.getNodeById?.(unique.targetNodeId) : null; return target && target.kind === 'method' && !sharesReceiverWord(receiverLink(receiver), target) ? null : unique; } function pythonRefClass(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { const imports = context.getImportMappings(ref.filePath, 'python'); // `import pkg.mod` then `pkg.mod.Cls`: the mapping keys the module by its last segment. const module = imports.find(i => i.isNamespace && name.startsWith(`${i.source}.`) && /^\w+$/.test(name.slice(i.source.length + 1))); if (module) { // Two modules with the same last segment (`import a.foo`, `import b.foo`) share the key: refuse. if (imports.filter(i => i.localName === module.localName).length !== 1) return null; name = `${module.localName}.${name.slice(module.source.length + 1)}`; } if (imports.some(i => i.localName === name.split('.')[0])) { const hit = context.resolveImport?.({ ...ref, referenceName: name, referenceKind: 'references' }); const node = hit && context.getNodeById?.(hit.targetNodeId); return node?.kind === 'class' && context.getNodesByQualifiedName(node.qualifiedName) .filter(n => n.kind === 'class' && n.filePath === node.filePath).length === 1 ? node : null; } const classes = context.getNodesByName(name).filter(n => n.kind === 'class' && n.filePath === ref.filePath); return classes.length === 1 ? classes[0]! : null; } function pythonBases(cls: Node, ref: UnresolvedRef, context: ResolutionContext): Node[] { const line = context.getFileLines?.(cls.filePath)?.[cls.startLine - 1] ?? context.readFile(cls.filePath)?.split('\n')[cls.startLine - 1] ?? ''; const bases = line.match(/^\s*class\s+\w+\s*\(([^)]*)\)/)?.[1]; return (bases?.split(',') ?? []).flatMap(name => { const base = pythonRefClass(name.trim(), { ...ref, filePath: cls.filePath }, context); return base ? [base] : []; }); } function pythonDerivesFrom(cls: Node, base: Node, ref: UnresolvedRef, context: ResolutionContext, seen = new Set()): boolean { if (seen.has(cls.id) || seen.size >= 16) return false; seen.add(cls.id); return pythonBases(cls, ref, context).some(p => p.id === base.id || pythonDerivesFrom(p, base, ref, context, seen)); } function pythonMembers(cls: Node, member: string, ref: UnresolvedRef, context: ResolutionContext, seen = new Set()): Node[] { if (seen.has(cls.id) || seen.size >= 16) return []; seen.add(cls.id); // Instance assignments also shadow methods, even though they are not nodes. const body = pythonMemberLines(cls.filePath, context).slice(cls.startLine - 1, cls.endLine).join('\n'); if (new RegExp(`^\\s*(?:(?:self|cls)\\.)?${escapeRegExp(member)}\\s*(?:=|:)`, 'm').test(body)) return [cls]; const own = context.getNodesByQualifiedName(`${cls.qualifiedName}::${member}`).filter(n => n.filePath === cls.filePath); if (own.length) return own; return [...new Map(pythonBases(cls, ref, context).flatMap(p => pythonMembers(p, member, ref, context, seen)).map(n => [n.id, n])).values()]; } function isPythonProperty(node: Node, context: ResolutionContext): boolean { if (node.language !== 'python' || node.kind !== 'method') return false; const lines = context.getFileLines?.(node.filePath) ?? context.readFile(node.filePath)?.split('\n') ?? []; for (let i = node.startLine - 2; i >= 0 && lines[i]!.trim().startsWith('@'); i--) { if (/^\s*@(?:property|(?:functools\.)?cached_property)\s*$/.test(lines[i]!)) return true; } return false; } const PYTHON_MEMBER_LINES = new WeakMap>(); function pythonMemberLines(filePath: string, context: ResolutionContext): string[] { let files = PYTHON_MEMBER_LINES.get(context); if (!files) { files = new Map(); PYTHON_MEMBER_LINES.set(context, files); } let lines = files.get(filePath); if (!lines) { lines = stripCommentsForRegex(context.readFile(filePath) ?? '', 'python').split('\n'); files.set(filePath, lines); } return lines; } function pythonLocalType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): string | null { if (!/^\w+$/.test(receiver)) return null; const caller = context.getNodeById?.(ref.fromNodeId); const lines = pythonMemberLines(ref.filePath, context); const declaration = new RegExp(`^\\s*${receiver}\\s*(?::\\s*["']?([\\w.]+)["']?)?\\s*=\\s*(.*)$`); const annotation = new RegExp(`^\\s*${receiver}\\s*:\\s*["']?([\\w.]+)`); for (let i = ref.line - 1; i >= (caller?.startLine ?? 1) - 1; i--) { const line = lines[i] ?? ''; const assigned = line.match(declaration); if (assigned) return assigned[1] ?? assigned[2]!.match(/^([A-Z][\w.]*)\s*\(/)?.[1] ?? ''; const declared = line.match(annotation)?.[1]; if (declared) return declared; } return caller?.signature?.match(new RegExp(`\\b${receiver}\\s*:\\s*["']?([\\w.]+)`))?.[1] ?? null; } /** A module-scope Python variable (not a class attribute or a function local). */ function isPythonModuleGlobal(node: Node, context: ResolutionContext): boolean { return node.language === 'python' && (node.kind === 'variable' || node.kind === 'constant') && !context.getNodesInFile(node.filePath).some(n => (n.kind === 'class' || n.kind === 'function' || n.kind === 'method') && n.startLine <= node.startLine && n.endLine >= node.startLine); } /** How one line binds a name: `global`, a plain `name = value` / `name: T`, an import, or any other binding. */ type PythonBinding = | { kind: 'global' } | { kind: 'assign'; type: string | null; value: string; line: number } | { kind: 'import'; key: string } | { kind: 'other' }; const PYTHON_STATEMENT_STARTS = new WeakMap>(); /** Per line: does it start a statement (bracket depth 0, no `\` continuation)? String contents are skipped. */ function pythonStatementStarts(filePath: string, context: ResolutionContext): boolean[] { let files = PYTHON_STATEMENT_STARTS.get(context); if (!files) { files = new Map(); PYTHON_STATEMENT_STARTS.set(context, files); } let starts = files.get(filePath); if (starts) return starts; starts = []; let depth = 0; let continued = false; for (const line of pythonMemberLines(filePath, context)) { starts.push(depth === 0 && !continued); let quote = ''; for (let i = 0; i < line.length; i++) { const c = line[i]!; if (quote) { if (c === '\\') i++; else if (c === quote) quote = ''; continue; } if (c === '"' || c === "'") quote = c; else if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1); } continued = /\\\s*$/.test(line); } files.set(filePath, starts); return starts; } /** Line indexes that belong to `scope` itself (null: the module), not to a def or class nested in it. */ function pythonOwnLines(scope: Node | null, filePath: string, context: ResolutionContext): number[] { const count = pythonMemberLines(filePath, context).length; const from = scope ? scope.startLine : 1; const to = scope ? Math.min(scope.endLine, count) : count; const nested = new Uint8Array(to - from + 1); for (const n of context.getNodesInFile(filePath)) { if ((n.kind !== 'function' && n.kind !== 'method' && n.kind !== 'class') || n.id === scope?.id) continue; if (n.startLine < from || n.endLine > to || (scope && n.startLine <= scope.startLine)) continue; nested.fill(1, n.startLine - from, n.endLine - from + 1); } const own: number[] = []; for (let l = from; l <= to; l++) if (!nested[l - from]) own.push(l - 1); return own; } /** Split `a = b = value` at its top-level assignment operators; null when the line assigns nothing. */ function pythonAssignment(line: string): { targets: string[]; value: string; augmented: boolean } | null { const targets: string[] = []; let depth = 0; let quote = ''; let start = 0; let augmented = false; for (let i = 0; i < line.length; i++) { const c = line[i]!; if (quote) { if (c === '\\') i++; else if (c === quote) quote = ''; continue; } if (c === '"' || c === "'") { quote = c; continue; } if (c === '(' || c === '[' || c === '{') { depth++; continue; } if (c === ')' || c === ']' || c === '}') { depth--; continue; } if (c !== '=' || depth !== 0) continue; const prev = line[i - 1] ?? ''; if (line[i + 1] === '=') { i++; continue; } // == if (prev === '!' || prev === ':') continue; // != and the walrus if ((prev === '<' || prev === '>') && line[i - 2] !== prev) continue; // <= >= const op = line.slice(start, i).match(/(?:\/\/|\*\*|>>|<<|[-+*/%&|^@])$/)?.[0]; if (op) augmented = true; targets.push(line.slice(start, i - (op?.length ?? 0))); start = i + 1; } return targets.length ? { targets, value: line.slice(start), augmented } : null; } /** * Every way the statement starting at line `index` binds `name`, read across * its continuation lines. Only statement lines can assign or import; any line * can bind through `as`, a loop, a lambda or the walrus. */ function pythonLineBindings(lines: string[], index: number, statements: boolean[], name: string): PythonBinding[] { let line = lines[index]!; if (statements[index]) for (let j = index + 1; j < lines.length && !statements[j]; j++) line += '\n' + lines[j]; const out: PythonBinding[] = []; // An import statement can name `name` on a continuation line: `from x import (\n name,\n)`. const imported = statements[index] ? line.match(/^\s*(?:from\s+([\w.]+)\s+)?import\s+([\s\S]*)$/) : null; if (imported) { const names = imported[2]!; // `from x import *` can bind any name. if (imported[1] && names.trim() === '*') return [{ kind: 'other' }]; if (!names.includes(name)) return out; for (const part of names.replace(/[()\\]/g, ' ').split(',')) { const m = part.trim().match(/^([\w.]+)(?:\s+as\s+(\w+))?$/); const local = m && (m[2] ?? (imported[1] ? m[1]! : m[1]!.split('.')[0]!)); if (local !== name) continue; out.push({ kind: 'import', key: imported[1] ? `${imported[1]}:${m![1]}` : `${m![2] ? m![1] : local}:*` }); } return out; } if (!line.includes(name)) return out; const word = new RegExp(`(? s.trim() === name) ? [declared[1] === 'global' ? { kind: 'global' } : { kind: 'other' }] : []; } if (statements[index]) { // A `case` pattern binds its capture names (`case [name]:`, `case Cls(k=name):`, `case name:`). if (/^\s*case\b/.test(line)) return [{ kind: 'other' }]; const assignment = pythonAssignment(line); if (assignment) { const target = assignment.targets.length === 1 && !assignment.augmented ? assignment.targets[0]!.trim() : ''; const annotated = target.match(new RegExp(`^${escapeRegExp(name)}\\s*:\\s*["']?([\\w.]+)["']?$`)); if (target === name || annotated) { out.push({ kind: 'assign', type: annotated?.[1] ?? null, value: assignment.value.trim(), line: index }); } else if (assignment.targets.some(t => word.test(t))) { out.push({ kind: 'other' }); } } else { const annotated = line.match(new RegExp(`^\\s*${escapeRegExp(name)}\\s*:\\s*["']?([\\w.]+)["']?\\s*$`)); if (annotated) out.push({ kind: 'assign', type: annotated[1]!, value: '', line: index }); else if (new RegExp(`^\\s*del\\b`).test(line)) out.push({ kind: 'other' }); } } if (new RegExp(`\\b${escapeRegExp(name)}[ \\t]*:=|\\bas[ \\t]+${escapeRegExp(name)}\\b`).test(line)) out.push({ kind: 'other' }); for (const loop of line.matchAll(/\bfor\s+([^:]+?)\s+in\b/g)) if (word.test(loop[1]!)) out.push({ kind: 'other' }); for (const lambda of line.matchAll(/\blambda\b([^:]*):/g)) if (word.test(lambda[1]!)) out.push({ kind: 'other' }); return out; } /** * Whether `name`, read at the ref, is bound by the calling function or one that * encloses it (parameter, assignment, loop, `as`, lambda, import) rather than * being the module global. With `importsBind` false, an import of the name is * not a shadow: it binds the same module the file imports. */ function pythonBindsLocally(name: string, ref: UnresolvedRef, context: ResolutionContext, importsBind: boolean): boolean { const lines = pythonMemberLines(ref.filePath, context); const statements = pythonStatementStarts(ref.filePath, context); const param = new RegExp(`[(,]\\s*\\*{0,2}${escapeRegExp(name)}\\s*[:=,)]`); const scopes = context.getNodesInFile(ref.filePath).filter(n => (n.kind === 'function' || n.kind === 'method') && n.startLine <= ref.line && n.endLine >= ref.line) .sort((a, b) => b.startLine - a.startLine); for (const scope of scopes) { const own = pythonOwnLines(scope, ref.filePath, context); const bindings = own.flatMap(i => pythonLineBindings(lines, i, statements, name)); if (bindings.some(b => b.kind === 'global')) return false; const def = own.find(i => /^\s*(?:async\s+)?def\b/.test(lines[i]!)); let header = scope.signature ?? ''; for (let j = def ?? lines.length; j < lines.length && (j === def || !statements[j]); j++) header += lines[j]; if (param.test(header)) return true; if (bindings.some(b => b.kind !== 'global' && (b.kind !== 'import' || importsBind))) return true; } return false; } /** * The module `name` is bound to by an `import a.b as name` written in the * function around `ref` — the innermost one that binds `name` at all (#2600). * Import mappings are per file and read only module-level `import` lines, so * such a function-local alias has none. Null when that function binds `name` * any other way, binds it to several modules, or imports an undotted module * (an optional dependency's name can match any project file's basename). */ export function pythonFunctionModuleAlias(name: string, ref: UnresolvedRef, context: ResolutionContext): string | null { if (ref.language !== 'python' || !/^\w+$/.test(name)) return null; const lines = pythonMemberLines(ref.filePath, context); const statements = pythonStatementStarts(ref.filePath, context); const scopes = context.getNodesInFile(ref.filePath).filter(n => (n.kind === 'function' || n.kind === 'method') && n.startLine <= ref.line && n.endLine >= ref.line) .sort((a, b) => b.startLine - a.startLine); for (const scope of scopes) { const bindings = pythonOwnLines(scope, ref.filePath, context).flatMap(i => pythonLineBindings(lines, i, statements, name)); if (bindings.length === 0) continue; const modules = new Set(bindings.map(b => b.kind === 'import' && b.key.endsWith(':*') ? b.key.slice(0, -2) : '')); const [module] = modules; return modules.size === 1 && module && module.includes('.') && !pythonBindsLocally(name, ref, context, false) ? module : null; } return null; } /** * How the code around a Python reference binds `name`, the receiver of a * dotted reference whose file imports that name: * - `'module'`: nothing nearer does (or the function declares it `global`), * so the file's own import is what the reference reads; * - `'local'`: the calling function, a function enclosing it, or the * reference's own statement binds it otherwise — a parameter, an * assignment (annotated and augmented ones too), a `for` / `with … as` / * `except … as` target, a walrus, `del`, a nested `def` or `class`, a * `case` capture, an `import` statement, or a lambda parameter or a * comprehension variable the reference sits inside; * - `{ source, exportedName }`: a function-local `from source import * exportedName as name`, the one import that binds it there; * - `{ module }`: a function-local `import module as name`, or `import name` * / `import name.sub` (which bind the top-level `name`). * A keyword argument, an attribute or subscript store, a comparison, text in * a string or a comment, and a nested function's own body bind nothing. * `def by_param(other): other.update(…)` and `with open(p) as other:` read * no module the file imports as `other`. */ export type PythonReceiverBinding = | 'module' | 'local' | { source: string; exportedName: string } | { module: string }; export function pythonReceiverBinding(name: string, ref: UnresolvedRef, context: ResolutionContext): PythonReceiverBinding { if (ref.language !== 'python' || !/^[A-Za-z_]\w*$/.test(name)) return 'module'; if (pythonStatementBindsAt(name, ref, context)) return 'local'; // Innermost first: a closure reads the binding of the function nearest it. const { functions, innermost, parent } = pythonFunctionIndex(ref.filePath, context); for (let i = innermost[ref.line] ?? -1; i >= 0; i = parent[i]!) { const scope = functions[i]!; const binding = pythonNameScan(context, `binding\0${scope.id}\0${name}`, () => pythonScopeBinding(scope, name, context)); if (binding !== null) return binding; } return 'module'; } /** * A file's functions and methods, the innermost one around each line (-1 for * none) and the one around each function's own `def` line, built once per file. */ function pythonFunctionIndex(filePath: string, context: ResolutionContext): { functions: Node[]; innermost: Int32Array; parent: Int32Array } { return pythonNameScan(context, `functions\0${filePath}`, () => { // Outer before inner: by start, the longer first. const functions = context.getNodesInFile(filePath) .filter((n) => n.kind === 'function' || n.kind === 'method') .sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine); const innermost = new Int32Array(functions.reduce((m, f) => Math.max(m, f.endLine), 0) + 2).fill(-1); const parent = new Int32Array(functions.length).fill(-1); functions.forEach((f, i) => { parent[i] = innermost[f.startLine]!; innermost.fill(i, f.startLine, f.endLine + 1); }); return { functions, innermost, parent }; }); } /** How the function `scope` itself binds `name`; null when it doesn't, and an enclosing scope decides. */ function pythonScopeBinding(scope: Node, name: string, context: ResolutionContext): PythonReceiverBinding | null { const { params, statements, nested } = pythonScopeText(scope, context); if (statements.some((st) => pythonDeclaresGlobal(st, name))) return 'module'; if (params.includes(name) || nested.includes(name)) return 'local'; let imported: Exclude | null = null; for (const st of statements) { const binding = pythonImportBinds(st, name); if (binding === undefined) { if (pythonStatementBinds(st, name)) return 'local'; } else if (binding) { // Two imports binding the name differently: which one a reference reads // depends on the path the function took. if (imported && JSON.stringify(imported) !== JSON.stringify(binding)) return 'local'; imported = binding; } } return imported; } /** * The parameter names of the function `scope`, the statements of its own * body (continuation lines joined, strings blanked, split on `;`), and the * names its nested `def`s and `class`es bind, whose bodies are not its own. */ function pythonScopeText(scope: Node, context: ResolutionContext): { params: string[]; statements: string[]; nested: string[] } { return pythonNameScan(context, `scope\0${scope.id}`, () => { const lines = pythonMemberLines(scope.filePath, context); const last = Math.min(scope.endLine, lines.length); const indent = (l: string) => l.length - l.trimStart().length; const statements: string[] = []; const nested: string[] = []; let header: string | null = null; let current: string | null = null; let depth = 0; let continued = false; let nestedIndent = -1; const flush = () => { if (current === null) return; if (header === null) header = current; else statements.push(...current.split(';').map((s) => s.trim()).filter(Boolean)); current = null; }; for (let k = scope.startLine; k <= last; k++) { const raw = lines[k - 1] ?? ''; const blanked = pythonBlankStrings(raw); const inside = depth > 0 || continued; for (const c of blanked) { if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1); } continued = /\\\s*$/.test(blanked); if (inside) { if (current !== null) current += ' ' + blanked.trim().replace(/\\$/, ''); continue; } if (!blanked.trim()) continue; flush(); if (nestedIndent >= 0) { if (indent(raw) > nestedIndent) continue; nestedIndent = -1; } const text = blanked.trim().replace(/\\$/, ''); // The scope's first line is its own `def` header. const def = k === scope.startLine ? null : /^(?:async\s+)?(?:def|class)\s+(\w+)/.exec(text); if (def) { nested.push(def[1]!); nestedIndent = indent(raw); continue; } current = text; } flush(); // `def name[T](a, *b, c: int = 1, **d)`: the names between the brackets. const head = header ?? ''; const open = /\bdef\s+\w+\s*(?:\[[^\]]*\]\s*)?\(/.exec(head); const params = open ? pythonParamNames(head.slice(open.index + open[0].length, pythonClosingParen(head, open.index + open[0].length - 1))) : []; return { params, statements, nested }; }); } /** The index of the bracket closing the one at `open`, or the text's end. */ function pythonClosingParen(text: string, open: number): number { let depth = 0; for (let i = open; i < text.length; i++) { const c = text[i]!; if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') { depth--; if (depth === 0) return i; } } return text.length; } /** `text` split at its top-level commas (none inside brackets). */ function pythonTopLevelParts(text: string): string[] { const parts: string[] = []; let depth = 0; let from = 0; for (let i = 0; i < text.length; i++) { const c = text[i]!; if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1); else if (c === ',' && depth === 0) { parts.push(text.slice(from, i)); from = i + 1; } } parts.push(text.slice(from)); return parts; } /** The names a parameter list binds: `a`, `*b`, `c: int = 1`, `**d`; never the bare `*` or `/` markers. */ function pythonParamNames(list: string): string[] { return pythonTopLevelParts(list) .map((p) => /^\s*\*{0,2}\s*([A-Za-z_]\w*)/.exec(p)?.[1]) .filter((p): p is string => p !== undefined); } /** `global name` (or a list naming it). */ function pythonDeclaresGlobal(statement: string, name: string): boolean { const m = /^global\s+(.+)$/.exec(statement); return !!m && m[1]!.split(',').some((n) => n.trim() === name); } /** * What an import statement binds `name` to: the module of `import a.b as * name`, or the top-level `name` of `import name` / `import name.sub`; the * source and name of `from a import b as name`; null for an import that * doesn't bind it, undefined for any other statement. */ function pythonImportBinds(statement: string, name: string): Exclude | null | undefined { const plain = /^import\s+([\s\S]+)$/.exec(statement); if (plain) { for (const part of plain[1]!.replace(/[()]/g, ' ').split(',')) { const m = /^([\w.]+)(?:\s+as\s+(\w+))?$/.exec(part.trim()); if (!m) continue; if (m[2] === name) return { module: m[1]! }; if (!m[2] && m[1]!.split('.')[0] === name) return { module: name }; } return null; } const from = /^from\s+(\.*[\w.]*)\s+import\s+([\s\S]+)$/.exec(statement); if (!from) return undefined; for (const part of from[2]!.replace(/[()]/g, ' ').split(',')) { const m = /^(\w+)(?:\s+as\s+(\w+))?$/.exec(part.trim()); if (m && (m[2] ?? m[1]) === name) return { source: from[1]!, exportedName: m[1]! }; } return null; } /** Whether one statement (strings blanked) binds `name` other than by an import. */ function pythonStatementBinds(statement: string, name: string): boolean { const n = escapeRegExp(name); if (pythonAssignTargets(statement).some((t) => pythonTargetBinds(t, name))) return true; // `name: T` declares a local without assigning it. if (new RegExp(`^${n}\\s*:(?!=)`).test(statement)) return true; const loop = /^(?:async\s+)?for\s+([\s\S]+?)\s+in\b/.exec(statement); if (loop && pythonTargetBinds(loop[1]!, name)) return true; // `with … as name`, `except … as name`, `case … as name`, `as (a, name)`. for (const m of statement.matchAll(/\bas\s+(\([^)]*\)|[\w.]+)/g)) if (pythonTargetBinds(m[1]!, name)) return true; if (new RegExp(`(? pythonTargetBinds(t, name))) return true; return pythonCaseCaptures(statement, name); } /** * Whether a `case` clause's pattern captures `name`: written bare, not as a * value pattern's dotted part (`other.MODE`), a class pattern's class * (`other(…)`) or a keyword pattern's attribute (`Point(other=1)`). `case = 1` * assigns a variable named `case`. */ function pythonCaseCaptures(statement: string, name: string): boolean { const m = /^case\b([\s\S]*)$/.exec(statement); if (!m || pythonAssignTargets(statement).length > 0) return false; let depth = 0; let end = -1; for (let i = 0; i < m[1]!.length && end < 0; i++) { const c = m[1]![i]!; if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1); else if (c === ':' && depth === 0) end = i; } if (end < 0) return false; const pattern = m[1]!.slice(0, end).replace(/\bif\b[\s\S]*$/, ''); for (const hit of pattern.matchAll(new RegExp(`(?= lines.length) return false; let first = at; while (first > 0 && !starts[first]) first--; let end = at; while (end + 1 < lines.length && !starts[end + 1]) end++; let text = ''; let offset = 0; for (let i = first; i <= end; i++) { if (i === at) offset = text.length + Math.max(0, ref.column ?? 0); text += pythonBlankStrings(lines[i]!) + '\n'; } if (!/\b(?:lambda|for)\b/.test(text)) return false; // A lambda's body runs from its `:` to the first comma or closing bracket of // its own level. for (const m of text.matchAll(/\blambda\b/g)) { const from = m.index! + 6; let depth = 0; let colon = -1; for (let i = from; i < text.length && colon < 0; i++) { const c = text[i]!; if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') { if (depth === 0) break; depth--; } else if (c === ':' && depth === 0) colon = i; } if (colon < 0 || offset <= colon || !pythonParamNames(text.slice(from, colon)).includes(name)) continue; let stop = text.length; depth = 0; for (let i = colon + 1; i < text.length; i++) { const c = text[i]!; if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') { if (depth === 0) { stop = i; break; } depth--; } else if (c === ',' && depth === 0) { stop = i; break; } } if (offset < stop) return true; } // A comprehension's `for` sits inside the brackets it binds in. const opens: number[] = []; const pairs: Array<[number, number]> = []; for (let i = 0; i < text.length; i++) { const c = text[i]!; if (c === '(' || c === '[' || c === '{') opens.push(i); else if ((c === ')' || c === ']' || c === '}') && opens.length) pairs.push([opens.pop()!, i]); } for (const o of opens) pairs.push([o, text.length]); for (const m of text.matchAll(/\bfor\b/g)) { const around = pairs.filter(([o, c]) => o < m.index! && c > m.index!).sort((a, b) => b[0] - a[0])[0]; if (!around || offset <= around[0] || offset >= around[1]) continue; const targets = /^\s*([\s\S]+?)\s+in\b/.exec(text.slice(m.index! + 3, around[1])); if (targets && pythonTargetBinds(targets[1]!, name)) return true; } return false; } /** * The imports binding `name` at module scope, or in a function declaring it * `global`, keyed as pythonLineBindings keys them: `from a import x as name` * → `a:x`, `import a.b as name` → `a.b:*`. */ export function pythonModuleImportKeys(name: string, filePath: string, context: ResolutionContext): Set { return new Set(pythonGlobalBindings(name, filePath, context).flatMap((b) => (b.kind === 'import' ? [b.key] : []))); } /** Distinct sources a file imports `name` from, at any scope (`from a import x` → `a:x`). */ function pythonImportKeys(name: string, filePath: string, context: ResolutionContext): Set { return pythonNameScan(context, `imports\0${filePath}\0${name}`, () => scanPythonImportKeys(name, filePath, context)); } const PYTHON_NAME_SCANS = new WeakMap>(); /** Per-file, per-name scans are shared by every ref in the file; cleared with the other memos on sync. */ function pythonNameScan(context: ResolutionContext, key: string, scan: () => T): T { let memo = PYTHON_NAME_SCANS.get(context); if (!memo) { memo = new Map(); PYTHON_NAME_SCANS.set(context, memo); } if (!memo.has(key)) memo.set(key, scan()); return memo.get(key) as T; } function scanPythonImportKeys(name: string, filePath: string, context: ResolutionContext): Set { const lines = pythonMemberLines(filePath, context); const statements = pythonStatementStarts(filePath, context); const keys = new Set(); for (let i = 0; i < lines.length; i++) { if (!statements[i] || !/\bimport\b/.test(lines[i]!)) continue; for (const b of pythonLineBindings(lines, i, statements, name)) if (b.kind === 'import') keys.add(b.key); } return keys; } /** Every binding of module global `name` in `filePath`: at module scope, and in each function that declares it `global`. */ function pythonGlobalBindings(name: string, filePath: string, context: ResolutionContext): PythonBinding[] { return pythonNameScan(context, `globals\0${filePath}\0${name}`, () => scanPythonGlobalBindings(name, filePath, context)); } function scanPythonGlobalBindings(name: string, filePath: string, context: ResolutionContext): PythonBinding[] { const lines = pythonMemberLines(filePath, context); const statements = pythonStatementStarts(filePath, context); const scopes = context.getNodesInFile(filePath).filter(n => n.kind === 'class' || n.kind === 'function' || n.kind === 'method'); const regions = [pythonOwnLines(null, filePath, context)]; for (let i = 0; i < lines.length; i++) { if (!/^\s*global\b/.test(lines[i]!) || !pythonLineBindings(lines, i, statements, name).length) continue; const scope = scopes.filter(n => n.startLine <= i + 1 && n.endLine >= i + 1).sort((a, b) => b.startLine - a.startLine)[0]; if (scope && scope.kind !== 'class') regions.push(pythonOwnLines(scope, filePath, context)); } const script = pythonMainBlockLines(filePath, context); return regions.flatMap(region => region.filter(i => !script.has(i)).flatMap(i => pythonLineBindings(lines, i, statements, name))) .filter(b => b.kind !== 'global'); } const PYTHON_GLOBAL_CLASSES = new WeakMap>(); /** * The classes a module global can hold. It has no static type (`conn = None`, * rebound by `global conn; conn = Backend()`), so its type is the set of * classes its module assigns to it: at module scope, or in a function that * declares it `global`. Any other binding of it there (an opaque value, a * tuple target, `for`/`with`/import, a star import, a `globals()` write) * makes the type unknown (null). Production writes from other modules * (`settings.conn = X`) join the set; see pythonExternalWrites. */ function pythonGlobalClasses(global: Node, ref: UnresolvedRef, context: ResolutionContext): Node[] | null { let memo = PYTHON_GLOBAL_CLASSES.get(context); if (!memo) { memo = new Map(); PYTHON_GLOBAL_CLASSES.set(context, memo); } if (memo.has(global.id)) return memo.get(global.id)!; const own = pythonOwnGlobalWrites(global, context); const external = own && pythonExternalWrites(global, context); // Each type is resolved in the file that wrote it (its imports name the class). const writes = external && !external.unknown ? [...external.writes, ...own!] : null; let classes: Node[] | null = writes && []; const seen = new Set(); for (const write of writes ?? []) { const cls = pythonRefClass(write.type, { ...ref, filePath: write.file }, context); if (!cls) { classes = null; break; } if (!seen.has(cls.id)) { seen.add(cls.id); classes!.push(cls); } } memo.set(global.id, classes); return classes; } /** * The types the global's own module writes to it, or null when a binding * there leaves its type unknown — whatever other modules write, so they are * not read (#2332). */ function pythonOwnGlobalWrites(global: Node, context: ResolutionContext): Array<{ type: string; file: string }> | null { return pythonNameScan(context, `own\0${global.id}`, () => { const file = global.filePath; if (pythonDynamicGlobalWrite(global.name, file, context)) return null; const writes: Array<{ type: string; file: string }> = []; for (const b of pythonGlobalBindings(global.name, file, context)) { if (b.kind !== 'assign') return null; const constructor = b.value && b.value !== 'None' ? pythonConstructorCall(b.value) : null; if (b.type) { // `conn: Base = make()` trusts the annotation; `conn: A = B()` contradicts it. if (constructor && constructor.split('.').pop() !== b.type.split('.').pop()) return null; writes.push({ type: b.type, file }); continue; } if (b.value === 'None') continue; if (!constructor) return null; writes.push({ type: constructor, file }); } return writes; }); } /** * The repo files a Python module path can name from `fromFile`. Relative * paths (`..settings`) resolve exactly; absolute ones match a file path * suffix, so a source root (`src/`) still resolves — and two files sharing * the tail (`x/settings.py`, `y/settings.py`) both come back. */ function pythonModuleFiles(dotted: string, fromFile: string, context: ResolutionContext): string[] { const dots = dotted.match(/^\.+/)?.[0].length ?? 0; const parts = dotted.slice(dots).split('.').filter(Boolean); if (!parts.length) return []; let dir = ''; if (dots) { dir = path.posix.dirname(fromFile.replace(/\\/g, '/')); for (let i = 1; i < dots; i++) dir = path.posix.dirname(dir); if (dir === '.') dir = ''; } const rel = [dir, ...parts].filter(Boolean).join('/'); const matches = (file: string, want: string) => dots ? file === want : file === want || file.endsWith(`/${want}`); const last = parts[parts.length - 1]!; return [ ...context.getNodesByName(`${last}.py`), ...context.getNodesByName(`${last}.pyi`), ...context.getNodesByName('__init__.py'), ].filter(n => n.kind === 'file' && (matches(n.filePath, `${rel}.py`) || matches(n.filePath, `${rel}.pyi`) || matches(n.filePath, `${rel}/__init__.py`))) .map(n => n.filePath); } /** * How `filePath` spells the module `moduleFile`: `aliases` import exactly that * file; `ambiguous` could also be another file sharing its dotted tail. * `import a.b` binds `a`, so that module is spelled `a.b` (the mapping's * last-segment `localName` is not a binding); `import a.b as c` binds `c`. */ function pythonModuleAliases(filePath: string, moduleFile: string, context: ResolutionContext): { aliases: string[]; ambiguous: string[] } { const aliases = new Set(); const ambiguous = new Set(); for (const m of context.getImportMappings(filePath, 'python')) { const files = pythonImportedFiles(m, filePath, context); if (!files.includes(moduleFile)) continue; // The mapping cannot tell `import a.b` from `import a.b as b`; the source line can. // Exactly this module (not `other.a.b`), outside string literals. const explicit = new RegExp(`\\bimport\\s[^\\n]*(? explicit.test(blankPythonStrings(line))); (files.length === 1 ? aliases : ambiguous).add(plainDotted ? m.source : m.localName); } return { aliases: [...aliases], ambiguous: [...ambiguous] }; } const PYTHON_IMPORTED_FILES = new WeakMap>(); /** * The repo files an import of `filePath` can name. Every global's write scan * asks again of the same files (#2332); kept for as long as the resolver keeps * the mapping itself, so the memo never outlives its import cache. */ function pythonImportedFiles(m: ImportMapping, filePath: string, context: ResolutionContext): string[] { let memo = PYTHON_IMPORTED_FILES.get(context); if (!memo) PYTHON_IMPORTED_FILES.set(context, (memo = new WeakMap())); let files = memo.get(m); if (!files) { const dotted = m.isNamespace ? m.source : /^\.+$/.test(m.source) ? `${m.source}${m.exportedName}` : `${m.source}.${m.exportedName}`; memo.set(m, (files = pythonModuleFiles(dotted, filePath, context))); } return files; } const PYTHON_MAIN_GUARD = /^if\s+(?:__name__\s*==\s*(['"])__main__\1|(['"])__main__\2\s*==\s*__name__)\s*:/; /** Line indexes inside a top-level `if __name__ == "__main__":` block — script code, not module state. */ function pythonMainBlockLines(filePath: string, context: ResolutionContext): Set { return pythonNameScan(context, `main\0${filePath}`, () => { const lines = pythonMemberLines(filePath, context); const inside = new Set(); for (let i = 0; i < lines.length; i++) { const guard = lines[i]!.match(PYTHON_MAIN_GUARD); if (!guard) continue; if (lines[i]!.slice(guard[0].length).trim()) inside.add(i); // `if __name__ == "__main__": stmt` for (let j = i + 1; j < lines.length && !/^\S/.test(lines[j]!); j++) inside.add(j); } return inside; }); } /** Blank single-line string contents (triple-quoted strings are already blanked by the comment stripper). */ function blankPythonStrings(text: string): string { return text.replace(/(['"])(?:\\.|(?!\1)[^\\\n])*\1/g, m => m[0] + ' '.repeat(m.length - 2) + m[0]); } /** Test code installs doubles: the narrow test-suite set, plus pytest's `conftest.py` wherever it sits. */ function isPythonTestFile(filePath: string): boolean { return isTestPath(filePath) || /(?:^|\/)conftest\.py$/.test(filePath.replace(/\\/g, '/')); } /** * Writes to module global `global` from OTHER files. A production write * `. = Cls(...)` adds a type; any other production write * (another value, a tuple target, `setattr`) makes the type unknown. Test * files install doubles (`settings.conn = MagicMock()`, `monkeypatch.setattr`) * that do not define the production type; they are recorded as `writers`, and * a ref inside a writer resolves nothing through the global. */ function pythonExternalWrites(global: Node, context: ResolutionContext): { writes: Array<{ type: string; file: string }>; unknown: boolean; writers: Set } { return pythonNameScan(context, `external\0${global.id}`, () => { const out = { writes: [] as Array<{ type: string; file: string }>, unknown: false, writers: new Set() }; const name = global.name; const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); for (const file of pythonWriteCandidates(name, context)) { if (file === global.filePath) continue; const test = isPythonTestFile(file); const { aliases, ambiguous } = pythonModuleAliases(file, global.filePath, context); if (!test && !aliases.length && !ambiguous.length) continue; const spell = (names: string[]) => `(?:${names.map(escape).join('|')})`; // Test files: any receiver (the double may sit on any spelling of the module). const receiver = test ? '[\\w.]+' : spell([...aliases, ...ambiguous]); const target = new RegExp(`(? target.test(t.trim())) ?? false; if (!assigned && !dynamic.test(statement)) continue; if (test) { out.writers.add(file); continue; } // A write through an ambiguous spelling may land on another module: unknown. const single = assignment && assignment.targets.length === 1 && !assignment.augmented && exact.test(assignment.targets[0]!.trim()); const value = single ? assignment!.value.trim() : ''; if (value === 'None') continue; const constructor = value ? pythonConstructorCall(value) : null; if (!constructor) { out.unknown = true; return out; } out.writes.push({ type: constructor, file }); } } return out; }); } /** * The Python files that can write a module global named `name`, in path * order: those that spell it after a dot (`settings.conn = X`) or a quote * (`setattr(settings, "conn", X)`), and those that write through `.__dict__`, * whose statement may spell it anywhere. Comment stripping only blanks text, * so whatever a stripped statement spells, the file's text spells too. The * files are indexed once per pass, instead of every global reading every * Python file (#2332). */ function pythonWriteCandidates(name: string, context: ResolutionContext): string[] { const index = pythonNameScan(context, 'write-candidates', () => { const files = context.getAllFiles().filter(f => /\.pyi?$/.test(f)); const spelled = new Map(); const dict: Array<{ at: number; words: string }> = []; files.forEach((file, at) => { const source = context.readFile(file) ?? ''; const names = new Set(); for (const m of source.matchAll(/[.'"](\w+)/g)) names.add(m[1]!); for (const n of names) { const list = spelled.get(n); // A capture is a sliced view that would pin the file's whole text: key a flat copy. if (list) list.push(at); else spelled.set(Buffer.from(n).toString(), [at]); } // A word-only name is in the text exactly when it is in one of its words. if (names.has('__dict__')) dict.push({ at, words: [...new Set(source.match(/\w+/g))].join('\n') }); }); return { files, spelled, dict }; }); // The index holds ASCII words; any other name is looked for in every file's text. if (!/^\w+$/.test(name)) return index.files.filter(f => context.readFile(f)?.includes(name)); const hits = new Set(index.spelled.get(name)); for (const { at, words } of index.dict) if (words.includes(name)) hits.add(at); return [...hits].sort((a, b) => a - b).map(at => index.files[at]!); } /** * Whether the global's own module can write it through its namespace dict: * `globals()` / `vars()` / `sys.modules[__name__]` used as anything but a * literal-key read or `.get`, or a literal-key write of this name. Read per * statement (continuations joined), with string contents ignored. */ function pythonDynamicGlobalWrite(name: string, filePath: string, context: ResolutionContext): boolean { const lines = pythonMemberLines(filePath, context); const statements = pythonStatementStarts(filePath, context); const script = pythonMainBlockLines(filePath, context); // `vars()` is the module dict only at module scope; inside a function it is the locals. const moduleLines = new Set(pythonOwnLines(null, filePath, context)); for (let i = 0; i < lines.length; i++) { if (!statements[i] || script.has(i)) continue; let statement = lines[i]!; for (let j = i + 1; j < lines.length && !statements[j]; j++) statement += '\n' + lines[j]; const code = blankPythonStrings(statement); const namespace = moduleLines.has(i) ? /\bglobals\(\s*\)|\bvars\(\s*\)|\bsys\.modules\s*\[\s*__name__\s*\]/g : /\bglobals\(\s*\)|\bsys\.modules\s*\[\s*__name__\s*\]/g; const uses = code.match(namespace)?.length ?? 0; if (!uses) continue; const targets = pythonAssignment(statement)?.targets ?? []; let safe = 0; for (const m of statement.matchAll(/\bglobals\(\s*\)\s*(?:\[\s*(['"])(\w+)\1\s*\]|\.\s*get\s*\()/g)) { const key = m[2]; const written = key !== undefined && targets.some(t => t.includes(m[0])); if (written && key === name) return true; safe++; } if (uses > safe) return true; } return false; } /** * What `receiver.member` reaches through a module-level instance of this * file — `_svc = SceneService()` at module scope, then `_svc.delete` (#2074) * or `_svc.delete(obj)` (#2582). Undefined when `receiver` is not that global * where the ref is: a parameter, a local, or a `def`/`class` written directly * in an enclosing function shadows it. Otherwise the one method or function every class the global * may hold has or inherits — never a property, an attribute or the referring * node itself (allowed for a `call`: recursion) — or null when there is none. */ function pythonGlobalReceiverTarget( receiver: string, member: string, ref: UnresolvedRef, context: ResolutionContext, call = false, ): Node | null | undefined { const global = pythonNameScan(context, `global\0${ref.filePath}\0${receiver}`, () => context.getNodesInFile(ref.filePath).find(n => n.name === receiver && isPythonModuleGlobal(n, context)) ?? null); if (!global || pythonBindsLocally(receiver, ref, context, true)) return undefined; // A `def _svc` / `class _svc` nested in an enclosing function binds the name there. const nodes = context.getNodesInFile(ref.filePath); const scopes = nodes.filter(n => (n.kind === 'function' || n.kind === 'method') && n.startLine <= ref.line && n.endLine >= ref.line); const innermost = (n: Node) => nodes .filter(c => c.id !== n.id && (c.kind === 'function' || c.kind === 'method' || c.kind === 'class') && c.startLine < n.startLine && c.endLine >= n.startLine) .sort((a, b) => b.startLine - a.startLine)[0]; if (nodes.some(n => n.name === receiver && (n.kind === 'class' || n.kind === 'function') && scopes.some(f => f.id === innermost(n)?.id))) return undefined; if (!pythonOwnGlobalWrites(global, context) || pythonExternalWrites(global, context).writers.has(ref.filePath)) { return null; } const classes = pythonGlobalClasses(global, ref, context); if (!classes) return null; // A class without the member (a base from outside the project may supply it) leaves it unknown. if (classes.some(cls => pythonMembers(cls, member, ref, context).length === 0)) return null; const pool = pythonGlobalMembers(global, member, ref, context).filter(n => sameLanguageFamily(n.language, ref.language)); const target = pool.length === 1 ? pool[0]! : undefined; // A method value naming its own method is no reference; a call through the global is recursion. return target && (target.kind === 'function' || target.kind === 'method') && (call || target.id !== ref.fromNodeId) && !isPythonProperty(target, context) ? target : null; } /** * The method a module global's value can dispatch to: one class resolves to * its own method; several bind to the nearest declaration they all inherit, * as a base-typed receiver does. Otherwise, no edge. */ function pythonGlobalMembers(global: Node, member: string, ref: UnresolvedRef, context: ResolutionContext): Node[] { // Unknown from its own module alone: no edge, and no other module to read. if (!pythonOwnGlobalWrites(global, context)) return []; // A test that installs its own double sees the double, not the production type. if (pythonExternalWrites(global, context).writers.has(ref.filePath)) return []; const classes = pythonGlobalClasses(global, ref, context); if (!classes) return []; const targets = [...new Map(classes.flatMap(cls => pythonMembers(cls, member, ref, context)).map(n => [n.id, n])).values()]; if (targets.length <= 1) return targets; // Several targets: the nearest declaration every candidate class inherits, // whether or not a candidate overrides it. const owner = (n: Node) => context.getNodesInFile(n.filePath).find(c => c.kind === 'class' && n.qualifiedName === `${c.qualifiedName}::${member}`); const declarations = new Map(); const queue = [...targets]; while (queue.length && declarations.size < 32) { const decl = queue.shift()!; const cls = decl && owner(decl); if (!cls || declarations.has(decl.id)) continue; declarations.set(decl.id, { decl, cls }); queue.push(...pythonBases(cls, ref, context).flatMap(base => pythonMembers(base, member, ref, context))); } const inherits = (cls: Node, base: Node) => cls.id === base.id || pythonDerivesFrom(cls, base, ref, context); const shared = [...declarations.values()].filter(d => classes.every(c => inherits(c, d.cls))); const nearest = shared.filter(d => shared.every(o => inherits(d.cls, o.cls))); return nearest.length === 1 ? [nearest[0]!.decl] : targets; } /** `Cls(...)` / `pkg.mod.Cls(...)` as the WHOLE (possibly multi-line) value; else null (`Cls() if x else y`). */ function pythonConstructorCall(text: string): string | null { // `(Cls())` is the same value; peel parentheses that wrap the whole expression. for (let wrapped = text.trim(); wrapped.startsWith('('); ) { let depth = 0; let close = -1; for (let i = 0; i < wrapped.length && close < 0; i++) { if (wrapped[i] === '(') depth++; else if (wrapped[i] === ')' && --depth === 0) close = i; } if (close !== wrapped.length - 1) break; text = wrapped = wrapped.slice(1, -1).trim(); } const callee = text.match(/^((?:[A-Za-z_]\w*\.)*[A-Z]\w*)\s*\(/); if (!callee) return null; let depth = 0; let quote = ''; for (let i = callee[0].length - 1; i < text.length; i++) { const c = text[i]!; if (quote) { if (c === '\\') i++; else if (c === quote) quote = ''; continue; } if (c === '"' || c === "'") quote = c; else if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') { if (--depth === 0) return /^[\s\\]*$/.test(text.slice(i + 1)) ? callee[1]! : null; } } return null; } /** Read a field's own annotation/initializer, or a constructor parameter assigned to it. */ function pythonFieldType(receiver: string, owner: Node, ref: UnresolvedRef, context: ResolutionContext): string | null { const lines = pythonMemberLines(ref.filePath, context); const field = receiver.split('.')[1]!; const assignment = new RegExp(`^\\s*(?:self|cls)\\.${escapeRegExp(field)}\\s*(?::\\s*["']?([\\w.]+)["']?)?\\s*=\\s*(.*)$`); const annotation = new RegExp(`^\\s*(?:(?:self|cls)\\.)?${escapeRegExp(field)}\\s*:\\s*["']?([\\w.]+)`); const methods = context.getNodesInFile(ref.filePath).filter(n => n.kind === 'method' && n.qualifiedName.startsWith(`${owner.qualifiedName}::`)); const types = new Set(); for (let i = owner.startLine; i < owner.endLine; i++) { const method = methods.find(n => n.startLine <= i + 1 && n.endLine >= i + 1); if (method && method.name !== '__init__' && method.id !== ref.fromNodeId) continue; if (method?.id === ref.fromNodeId && i + 1 > ref.line) continue; const line = lines[i] ?? ''; const declared = !method || /^\s*(?:self|cls)\./.test(line) ? line.match(annotation)?.[1] : undefined; if (declared) types.add(declared); const assigned = line.match(assignment); if (!assigned) continue; if (assigned[1]) { types.add(assigned[1]); continue; } const constructor = assigned[2]!.match(/^([A-Z][\w.]*)\s*\(/)?.[1]; if (constructor) { types.add(constructor); continue; } const param = assigned[2]!.trim(); if (method && /^\w+$/.test(param)) { const signature = method.signature ?? ''; const type = signature.match(new RegExp(`\\b${param}\\s*:\\s*["']?([\\w.]+)`))?.[1]; if (type) types.add(type); } else { types.add(''); } } // Conflicting assignments are known-but-ambiguous, never a name-only fallback. return types.size === 1 ? [...types][0]! : types.size > 1 ? '' : null; } /** * Resolve a function-as-value reference (#756) — a function name used as a * callback/function-pointer value (`register(handler)`, `o->cb = handler`, * `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed * for `function_ref` refs: exact name, function/method targets only, same * language family, same-file first for bare names, and unique-only cross-file. * Member values use receiver/type/import scope before a unique-name fallback. * A wrong callback edge is worse than none. */ export function matchFunctionRef( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { // `this.` refs are resolved ONLY by the class-scoped resolver in // resolveOne (resolveThisMemberFnRef) — never by name matching here. if (ref.referenceName.startsWith('this.')) return null; if ((ref.language === 'python' || ref.language === 'go') && ref.referenceName.includes('.')) { return matchMemberFunctionRef(ref, context); } // In JS/TS/Python a bare identifier can never be a method value (methods // are only reachable through a receiver — `this.m` / `self.m` / // `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the // pre-existing TS quirk of class fields extracting as method-kind nodes, // which otherwise soaked up local names passed as arguments (excalidraw // A/B finding; same pattern in vendored docopt.py). Python's `self.m` // form keeps method targets via its own capture shape. C++ likewise: a // bare identifier can only be a FREE function (member values need // `&Cls::method`). PHP string callables name global FUNCTIONS (methods // need the `[$obj, 'm']` array form, which carries its own shape). Other // languages keep method targets: C# method groups, Swift/Dart // implicit-self, Java/Kotlin method references. const bareFnOnly = ref.language === 'typescript' || ref.language === 'tsx' || ref.language === 'javascript' || ref.language === 'jsx' || ref.language === 'arkts' || ref.language === 'cpp' || ref.language === 'python' || ref.language === 'php'; // Python additionally accepts CLASS targets for bare identifiers (#1478): // class-as-value is a core Python idiom (`return SomeSerializer`, // `Meta.model = Org`, registry dicts, `admin.site.register(Model, Admin)`) // and, unlike TS, Python has no type-annotation recovery path. The // false-positive mechanism behind the function-only rule was lowercase // locals colliding with same-named METHODS (docopt.py) — a candidate must // be an exact-name CLASS node here, and the extraction gate (same-file // class ∪ imports) plus unique-or-drop still apply. Methods stay excluded. const bareClassOk = ref.language === 'python'; // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"): // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m` // shape is an explicit member reference). Unique-or-drop like everything else. if (ref.referenceName.includes('::')) { const memberName = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2); const scoped = context .getNodesByName(memberName) .filter( (n) => (n.kind === 'function' || n.kind === 'method') && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId && (n.qualifiedName === ref.referenceName || n.qualifiedName.endsWith(`::${ref.referenceName}`)) ); if (scoped.length === 0) return null; const sameFileScoped = scoped.filter((n) => n.filePath === ref.filePath); const pool = sameFileScoped.length > 0 ? sameFileScoped : scoped; if (sameFileScoped.length === 0 && scoped.length > 1) return null; const target = pool.reduce((a, b) => (a.startLine <= b.startLine ? a : b)); return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'function-ref', }; } const named = context .getNodesByName(ref.referenceName) .filter( (n) => (n.kind === 'function' || (!bareFnOnly && n.kind === 'method') || (bareClassOk && n.kind === 'class')) && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId // a function registering itself is not a dependency edge ); // A function declared inside another is in scope only in there: httpx's // `self._build_auth(auth)` passes its own parameter, not the `auth` a test // defines inside `test_custom_auth`. Those still count against a lone // cross-file guess below — a name several functions use for themselves is // as likely a local's. let candidates = named.filter((n) => isLexicallyReachable(n, ref, context) && // A Dart constructor is torn off through its class, never by a bare name. !isDartConstructor(n, context)); if (candidates.length === 0) return null; // A Python name the function around it binds — a parameter, an assignment — // is that local's value: httpx's `auth_flow(self, request)` handing `request` // on is not the package's `request()` function. A pytest fixture is what a // test's parameter of its name receives. if (ref.language === 'python' && !candidates.some((n) => isFixtureInReach(n, ref.filePath, context)) && isPythonLocallyBound(ref.referenceName, ref, context)) return null; // Likewise a JS/TS parameter or local: lodash's `baseHas(object, key)` passes its own `object`. const jsLocal = jsFunctionLocalScope(ref.referenceName, ref, context); if (jsLocal) { candidates = candidates.filter((n) => n.filePath === ref.filePath && n.startLine >= jsLocal.start && n.startLine <= jsLocal.end); if (candidates.length === 0) return null; } // Swift implicit-self: a bare identifier can name a METHOD only of the // ENCLOSING type (`Button(action: handleTap)` written inside that type) — // a same-named method on any OTHER class is a parameter collision // (Alamofire: a `request` parameter resolving to EventMonitor::request). // Scope method candidates to the from-symbol's type; top-level code has no // implicit self, so method targets are excluded there entirely. Free // functions are unaffected. if (ref.language === 'swift' && candidates.some((n) => n.kind === 'method')) { const fromNode = context.getNodeById?.(ref.fromNodeId); const sep = fromNode ? fromNode.qualifiedName.lastIndexOf('::') : -1; const classPrefix = fromNode && sep > 0 ? fromNode.qualifiedName.slice(0, sep) : null; candidates = candidates.filter((n) => { if (n.kind !== 'method') return true; if (!classPrefix) return false; const mSep = n.qualifiedName.lastIndexOf('::'); if (mSep <= 0) return false; const methodPrefix = n.qualifiedName.slice(0, mSep); // Accept exact-scope matches plus suffix relationships either way, so // extension-declared members (`Holder::m`) still match a nested // from-scope (`Module::Holder::wire`) and vice versa. return ( methodPrefix === classPrefix || methodPrefix.endsWith(`::${classPrefix}`) || classPrefix.endsWith(`::${methodPrefix}`) ); }); if (candidates.length === 0) return null; } // Same-file definition wins — the extraction gate guarantees most survivors // have one, and it's the dominant C pattern (static callback registered in // a same-file ops struct). const sameFile = candidates.filter((n) => n.filePath === ref.filePath); if (sameFile.length > 0) { // Swift: several same-named METHODS in one file is an API overload family // (`Session.request(...)` × N), and a bare identifier hitting it is almost // always a same-named parameter, not a method value (Alamofire A/B // finding) — refuse rather than guess. A single method (SwiftUI's // `action: handleTap`) still resolves. if ( ref.language === 'swift' && sameFile.length > 1 && sameFile.every((n) => n.kind === 'method') ) { return null; } // Same-name overloads in one file are the same conceptual symbol; pick // the first by position for determinism. const target = sameFile.reduce((a, b) => (a.startLine <= b.startLine ? a : b)); return { original: ref, targetNodeId: target.id, confidence: sameFile.length === 1 ? 0.95 : 0.9, resolvedBy: 'function-ref', }; } // Cross-file (imported names the import resolver didn't already claim): // only an unambiguous match resolves — or, in Python, the one in reach of // a name the file imports (netbox's `sender=CustomField` beside a test's // own nested `CustomField`). Never a name the file imports from a package // outside the repository, which no project function is: outline's // `styled(Link)`, with `Link` from react-router-dom, is not an email // template's `Link`, nor is cord-field's lodash `noop` a test's own stub. // Nor a name a project import binds to something else (importBindsOther). if (candidates.length === 1 && (named.length === 1 || (ref.language === 'python' && pythonFromImports(ref.filePath, context).has(ref.referenceName))) && !(JS_FAMILY.has(ref.language) && isOutOfRepoBinding(ref.referenceName, ref, context)) && !(JS_FAMILY.has(ref.language) && importBindsOther(candidates[0]!, ref, context))) { return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'function-ref', }; } return null; } /** Languages with no nested named functions: nesting in the graph is never a scope. */ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); /** Types a function body can declare for itself. */ const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface', 'trait', 'type_alias']); /** * A function nested inside another FUNCTION is only callable from within its * container — Python, JS/TS, and every closure language scope it lexically. * Resolving a bare name from elsewhere to a nested local fabricates an edge * scope already rules out: `join(...)` in one function must never bind to a * `join` defined inside a DIFFERENT function (#1230). A candidate whose * qualifiedName parent is a same-file function/method is kept only when the * ref originates inside that parent's line range. Class members are * unaffected (their parent resolves to a class-like node), as are top-level * symbols and C++ namespace-prefixed names (the prefix has no node). */ export function isLexicallyReachable( candidate: Node, ref: UnresolvedRef, context: ResolutionContext ): boolean { // An anonymous class's member — Java's `new Listener() { … }`, Kotlin's // `object : Listener { … }` — is named only inside the declaration holding // the class: a bare `block()` elsewhere is never the `override val block` of // an object a private property holds. Its overrides are reached through the // type it implements. const anon = candidate.qualifiedName.includes('$anon@') ? ANON_MEMBER_QN.exec(candidate.qualifiedName) : null; if (anon) { if (ref.filePath !== candidate.filePath) return false; // The holder is the declaration of that name around the class's own line // (overloads share the name). const at = Number(anon[2]); let holders = context.getNodesByQualifiedName(anon[1]!) .filter((n) => n.filePath === candidate.filePath && n.startLine <= at && (n.endLine ?? n.startLine) >= at); // A Kotlin extension function is named by its receiver (`Call::asObservable`), // not by the path its object's members carry (`pkg::asObservable`): then // the innermost declaration around the class's line holds it. if (holders.length === 0) { const around = context.getNodesInFile(candidate.filePath).filter((n) => ANON_HOLDER_KINDS.has(n.kind) && n.startLine <= at && (n.endLine ?? n.startLine) >= at && !n.qualifiedName.includes('$anon@')); const innermost = around.sort((x, y) => y.startLine - x.startLine)[0]; if (innermost) holders = [innermost]; } return holders.length === 0 || holders.some((n) => ref.line >= n.startLine && ref.line <= (n.endLine ?? n.startLine)); } // An object literal's member is reached through its object (#2300): a bare // `load()` or `setTimeout(load)` never means `App.load`, whatever shares // the name — see isObjectMemberReachableByName for the ways that do. if (candidate.kind === 'function' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) && !isObjectMemberReachableByName(candidate, ref, context)) return false; // An object hung on a path (`App.utils = {…}`, `$.event.special.swipe = // {…}`) is a property, not a binding: no bare name reaches it — a // `this.swipe()` elsewhere is not that object. One hung on the global object // (`window.App = {…}`) is the global `App`. if (isPathHolder(candidate) && isObjectLiteralOwner(candidate) && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) && !HOST_GLOBAL_PREFIX.test(lastQualifiedSegment(candidate.qualifiedName))) return false; // A `val` / `const` declared in a function body is that body's alone — // okio's `(source as Source).buffer()` bound to a `val buffer = Buffer()` // inside another test file's `pipe()`. if (candidate.kind === 'variable' || candidate.kind === 'constant' || (candidate.kind === 'field' && candidate.language === 'scala')) { const scope = localDeclarationScope(candidate, context); return scope === null || (ref.filePath === candidate.filePath && ref.line >= scope.start && ref.line <= scope.end); } // A function — or a type (`case class B()` in a test method), or a method // of such a type — declared inside a function is only in scope in there. if (candidate.kind !== 'function' && candidate.kind !== 'method' && !LOCAL_TYPE_KINDS.has(candidate.kind)) return true; // C and C++ have no nested named functions, so a function the graph shows // inside another is an extraction artifact, not a scope: tree-sitter-c // cannot parse a macro call whose arguments are designated initializers // (betaflight's `RESET_CONFIG(pidProfile_t, pidProfile, .pid = {…})`), and // its error recovery runs the enclosing function_definition to the end of // the file, nesting every function after it. Trusting that nesting rejected // 117 real calls into pid.c on that tree; the functions are reachable. if (NO_NESTED_FUNCTIONS.has(candidate.language)) return true; const scope = lexicalScopeOf(candidate, context); return scope === null || (ref.filePath === candidate.filePath && ref.line >= scope.start && ref.line <= scope.end); } /** A qualified name through an anonymous class: the declaration holding it, then `::`. */ const ANON_MEMBER_QN = /^(.+?)::<[^<>:]*\$anon@(\d+)>(?:::|$)/; /** Declarations an anonymous class can be written in. */ const ANON_HOLDER_KINDS = new Set(['function', 'method', 'constant', 'field', 'variable', 'property']); /** An anonymous class — Java's `new T() { … }`, Kotlin's `object : T { … }` — named ``. */ function isAnonymousClass(n: Node): boolean { return n.name.startsWith('<') && n.name.includes('$anon@'); } /** A member of an anonymous class: its qualified name passes through ``. */ function isAnonymousMember(n: Node): boolean { return n.qualifiedName.includes('$anon@') && ANON_MEMBER_QN.test(n.qualifiedName); } /** How many anonymous classes a qualified name passes through. */ function anonymousDepth(qualifiedName: string): number { let count = 0; for (let i = qualifiedName.indexOf('$anon@'); i >= 0; i = qualifiedName.indexOf('$anon@', i + 6)) count++; return count; } /** Per context: node id → the function body (or Scala block) a declaration is local to. */ const LOCAL_DECL_MEMO = new WeakMap>(); /** * The lines a variable declaration is in scope for when it is local: the * innermost function or method of its file whose lines hold it — or, for a * Scala `val` the graph files under its class but written inside a block of * the class body (cats' `test("…") { val f = … }`), that block. Null for a * declaration at file, class or object level. A Lua global assigned inside a * function is still global; C and C++ nesting is not trusted (see below). */ function localDeclarationScope(candidate: Node, context: ResolutionContext): { start: number; end: number } | null { if (NO_NESTED_FUNCTIONS.has(candidate.language)) return null; let memo = LOCAL_DECL_MEMO.get(context); if (!memo) LOCAL_DECL_MEMO.set(context, (memo = new Map())); const hit = memo.get(candidate.id); if (hit !== undefined) return hit; let scope: { start: number; end: number } | null = null; if (!((candidate.language === 'lua' || candidate.language === 'luau') && !isLuaLocal(candidate, context))) { for (const n of context.getNodesInFile(candidate.filePath)) { if ((n.kind !== 'function' && n.kind !== 'method') || n.id === candidate.id) continue; if (n.startLine > candidate.startLine || n.endLine < candidate.startLine || n.startLine === n.endLine) continue; if (n.startLine === candidate.startLine && (n.startColumn ?? 0) >= (candidate.startColumn ?? 0)) continue; if (!scope || n.endLine - n.startLine < scope.end - scope.start) scope = { start: n.startLine, end: n.endLine }; } if (!scope && candidate.kind === 'field' && candidate.language === 'scala') scope = scalaBlockOf(candidate, context); } memo.set(candidate.id, scope); return scope; } /** The `{ … }` block, deeper than its class body, that a Scala `val` is written in; null for a member. */ function scalaBlockOf(candidate: Node, context: ResolutionContext): { start: number; end: number } | null { const owner = candidate.qualifiedName.includes('::') ? candidate.qualifiedName.slice(0, candidate.qualifiedName.lastIndexOf('::')) : ''; const cls = context.getNodesInFile(candidate.filePath).find((n) => n.qualifiedName === owner && (n.kind === 'class' || n.kind === 'trait' || n.kind === 'struct' || n.kind === 'module')); if (!cls || cls.startLine >= candidate.startLine) return null; const lines = context.getFileLines?.(candidate.filePath) ?? context.readFile(candidate.filePath)?.split(/\r?\n/) ?? []; const clean = (l: string) => l.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)'/g, '""').replace(/\/\/.*$/, ''); // Depth at the start of each line, counted from the class header; the body is depth 1. const opens: number[] = []; let depth = 0; for (let line = cls.startLine; line < candidate.startLine; line++) { for (const ch of clean(lines[line - 1] ?? '')) { if (ch === '{') { depth++; opens.push(line); } else if (ch === '}') { depth = Math.max(0, depth - 1); opens.pop(); } } } if (depth <= 1) return null; const start = opens[opens.length - 1]!; let d = depth; for (let line = candidate.startLine; line <= cls.endLine; line++) { for (const ch of clean(lines[line - 1] ?? '')) { if (ch === '{') d++; else if (ch === '}' && --d < depth) return { start, end: line }; } } return { start, end: cls.endLine }; } /** Per context: a candidate's scoping function body, or null when nothing scopes it. */ const LEXICAL_SCOPE_MEMO = new WeakMap>(); /** * The innermost function or method BODY that scopes a declaration, walking out * through its qualified name (`test_x::Request::User::has_perm` → `test_x`). * A method directly on a class is reachable through its instances, and an * object-literal method a function returns through the object — neither is * scoped by the function it sits in. */ function lexicalScopeOf(candidate: Node, context: ResolutionContext): { start: number; end: number } | null { let memo = LEXICAL_SCOPE_MEMO.get(context); if (!memo) { memo = new Map(); LEXICAL_SCOPE_MEMO.set(context, memo); } const hit = memo.get(candidate.id); if (hit !== undefined) return hit; let scope: { start: number; end: number } | null = null; const own = candidate.qualifiedName ?? ''; const parentQn = own.includes('::') ? own.slice(0, own.lastIndexOf('::')) : ''; let qn = own; while (qn.includes('::')) { qn = qn.slice(0, qn.lastIndexOf('::')); const container = context .getNodesByQualifiedName(qn) .find( (p) => p.filePath === candidate.filePath && (p.kind === 'function' || p.kind === 'method') && p.startLine <= candidate.startLine && p.endLine >= candidate.endLine ); if (!container) continue; if (candidate.kind === 'method' && qn === parentQn) break; scope = { start: container.startLine, end: container.endLine }; break; } memo.set(candidate.id, scope); return scope; } /** Languages whose module boundary is `import`/`export` (or CommonJS). */ const ESM_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); /** * The ES-module family with single-file components' script blocks: a file * declaring the class an import names (tsImportedTypeFile) is the same * signal in each. */ const ES_IMPORT_LANGUAGES = new Set([...ESM_FAMILY, 'vue', 'svelte', 'astro']); /** * A line-initial `import` statement — the marker that a JS/TS file is a MODULE * rather than a classic script. Line-anchored and followed by a name, brace, * star or quote, so a dynamic `import(` and the word inside a comment or string * do not match. */ const HAS_IMPORT_STATEMENT = /^[ \t]*import[\s{*'"]/m; /** * Anything the file could offer another file, in every form the extractor's own * `isExported` flag misses. `^export` covers the declaration and later forms * (`export const`, `export { x }`, `export default x`, `export *`); the * CommonJS shapes cover files that never use ESM syntax at all, in both the dot * and the bracket form; and `declare global` contributes names to every file * whether or not the module exports anything of its own. Kept as a source test * rather than a node scan precisely because `isExported` is set only where a * declaration is written (an `export_statement` around it, or a `declare * module` / `declare global` body), so `const x = …; export { x }` and * `module.exports = { x }` both read as unexported on the node. */ const HAS_ESM_EXPORT = /^[ \t]*export[\s{*]|^[ \t]*declare\s+global\b/m; const HAS_CJS_EXPORT = /\bmodule\.exports\b|\bexports\s*[.[]/; /** * Per-context memo of "this file is a module that exports nothing", asked once * per candidate FILE rather than once per reference. Derived from file source, * so it drops with the context's file caches — clearNameMatcherMemos deletes it * alongside INFER_SCAN_STATES. */ const SEALED_MODULES = new WeakMap>(); /** * Whether `filePath` is a JS/TS module that exports NOTHING — an import * statement present, no export of any form. No reference from another file can * reach any binding in such a file, so every one of its symbols is a false * candidate for a cross-file name match. * * This is the general case behind a package name capturing a same-named local: * on `vitejs/vite`, 157 cross-file `imports` refs — every `import { defineConfig * } from 'vite'` in the playground and the create-vite templates — resolved onto * `playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await * createServer(…)` at module scope in a file with zero exports. The existing * guards cannot see it: `isLexicallyReachable` returns early for any candidate * that is not a `function`, and the bare-import guard correctly declines because * `vite` IS a workspace member, so the specifier really is project-local. What * is wrong is only which node the name lands on. * * Deliberately narrow on three axes, because each is a class this would * otherwise resolve wrongly in the opposite direction: * * - **A classic script is exempt.** Requiring an `import` statement means a * non-module `.js` file — concatenated globals, a browser `