fix(cpp): resolve callers for typed pointer method calls (#445)

Resolves typed member-pointer method calls like `m_cpAlg->Processing()` so `codegraph callers CDetect::Processing` returns the expected callers.

- Extract C/C++ `field_expression` member calls as receiver-qualified references, so `ptr->method()` is preserved as a receiver-aware reference.
- Surface out-of-line C++ method definitions (`int CDetect::Processing() {...}` in `.cpp` with class in `.hpp`) as proper method nodes with the correct qualified identity.
- C++ receiver-type inference: declarator regex requires a terminator after the receiver (rules out matching `return m_cpAlg->...`), handles `Type*x`/`Type *x`/`Type* x` uniformly, and rejects C++ keywords as a final guard.
- `resolveMethodOnType` matches by `Class::method` qualified-name suffix, so out-of-line definitions across files resolve (typical `.hpp`/`.cpp` split).

Validated on bitcoin-core (1306 .cpp files): 38,180 → 40,503 cpp method incoming-call edges (+6.1%), deterministic across re-indexes. Regression test added for the ambiguous-name + `return ptr->m()` / `Type x = ptr->m()` patterns.

Closes #445

Co-authored-by: chenyuxuan <458254969@qq.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thismilktea
2026-05-26 15:17:07 -05:00
committed by GitHub
co-authored by chenyuxuan Colby McHenry Claude Opus 4.7
parent 72c08c2bef
commit c0cf9c1e7d
5 changed files with 294 additions and 3 deletions
+47
View File
@@ -2,6 +2,51 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getChildByField, getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined {
const declarator = getChildByField(node, 'declarator');
if (!declarator) return undefined;
const queue: SyntaxNode[] = [declarator];
while (queue.length > 0) {
const current = queue.shift()!;
if (current.type === 'qualified_identifier') {
const text = getNodeText(current, source).trim();
const parts = text.split('::').filter(Boolean);
return parts[parts.length - 1];
}
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child) queue.push(child);
}
}
return undefined;
}
function extractCppReceiverType(node: SyntaxNode, source: string): string | undefined {
const declarator = getChildByField(node, 'declarator');
if (!declarator) return undefined;
const queue: SyntaxNode[] = [declarator];
while (queue.length > 0) {
const current = queue.shift()!;
if (current.type === 'qualified_identifier') {
const text = getNodeText(current, source).trim();
const parts = text.split('::').filter(Boolean);
if (parts.length > 1) {
return parts.slice(0, -1).join('::');
}
return undefined;
}
for (let i = 0; i < current.namedChildCount; i++) {
const child = current.namedChild(i);
if (child) queue.push(child);
}
}
return undefined;
}
export const cExtractor: LanguageExtractor = {
functionTypes: ['function_definition'],
classTypes: [],
@@ -62,6 +107,8 @@ export const cppExtractor: LanguageExtractor = {
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
resolveName: extractCppQualifiedMethodName,
getReceiverType: extractCppReceiverType,
getVisibility: (node) => {
// Check for access specifier in parent
const parent = node.parent;
+8 -3
View File
@@ -1504,10 +1504,11 @@ export class TreeSitterExtractor {
const func = getChildByField(node, 'function') || node.namedChild(0);
if (func) {
if (func.type === 'member_expression' || func.type === 'attribute' || func.type === 'selector_expression' || func.type === 'navigation_expression') {
if (func.type === 'member_expression' || func.type === 'attribute' || func.type === 'selector_expression' || func.type === 'navigation_expression' || func.type === 'field_expression') {
// Method call: obj.method() or obj.field.method()
// Go uses selector_expression with 'field', JS/TS uses member_expression with 'property'
// Kotlin uses navigation_expression with navigation_suffix > simple_identifier
// C/C++ use field_expression for both `obj.method()` and `ptr->method()`
let property = getChildByField(func, 'property') || getChildByField(func, 'field');
if (!property) {
const child1 = func.namedChild(1);
@@ -1524,9 +1525,13 @@ export class TreeSitterExtractor {
// This helps the resolver distinguish method calls from bare function calls
// (e.g., Python's console.print() vs builtin print())
// Skip self/this/cls as they don't aid resolution
const receiver = getChildByField(func, 'object') || getChildByField(func, 'operand') || func.namedChild(0);
const receiver =
getChildByField(func, 'object') ||
getChildByField(func, 'operand') ||
getChildByField(func, 'argument') ||
func.namedChild(0);
const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier')) {
if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) {
const receiverName = getNodeText(receiver, this.source);
if (!SKIP_RECEIVERS.has(receiverName)) {
calleeName = `${receiverName}.${methodName}`;
+133
View File
@@ -146,6 +146,122 @@ export function matchByQualifiedName(
return null;
}
function resolveMethodOnType(
typeName: string,
methodName: string,
ref: UnresolvedRef,
context: ResolutionContext,
confidence: number,
resolvedBy: ResolvedRef['resolvedBy'],
): ResolvedRef | null {
// Look up methods by name and match by qualifiedName ending in
// `<typeName>::<methodName>`. This works whether the method is defined
// in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate
// file (`int Foo::bar() { ... }` in foo.cpp while class Foo is in foo.hpp).
// The previous same-file approach missed the latter — the typical C++ layout.
const methodCandidates = context.getNodesByName(methodName);
const want = `${typeName}::${methodName}`;
for (const m of methodCandidates) {
if (m.kind !== 'method') continue;
if (m.language !== ref.language) continue;
const qn = m.qualifiedName;
if (qn === want || qn.endsWith(`::${want}`)) {
return {
original: ref,
targetNodeId: m.id,
confidence,
resolvedBy,
};
}
}
return null;
}
// C++ keywords/control-flow tokens that can appear right before a receiver
// (e.g. `return ptr->m()`) and must NOT be treated as a type.
const CPP_NON_TYPE_TOKENS = new Set([
'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
'break', 'continue', 'goto', 'throw', 'new', 'delete', 'co_await', 'co_yield',
'co_return', 'static_cast', 'const_cast', 'dynamic_cast', 'reinterpret_cast',
'sizeof', 'alignof', 'typeid', 'and', 'or', 'not', 'xor',
]);
function normalizeCppTypeName(typeName: string): string | null {
const normalized = typeName
.replace(/\b(const|volatile|mutable|typename|class|struct)\b/g, ' ')
.replace(/[&*]+/g, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return null;
const parts = normalized.split(/::/).filter(Boolean);
const last = parts[parts.length - 1];
if (!last) return null;
if (CPP_NON_TYPE_TOKENS.has(last)) return null;
return last;
}
// Declarator regex: matches `Type receiver`, `Type* receiver`, `Type *receiver`,
// `Type*receiver`, `Type<X> receiver`, etc., REQUIRING a declarator terminator
// (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The
// terminator rules out uses like `return receiver->m()` where the preceding
// token is a keyword, not a type.
function buildDeclaratorRegex(escapedReceiver: string): RegExp {
return new RegExp(
`([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`,
);
}
function inferCppReceiverType(
receiverName: string,
ref: UnresolvedRef,
context: ResolutionContext,
): string | null {
const source = context.readFile(ref.filePath);
if (!source) return null;
const lines = source.split(/\r?\n/);
const callLineIndex = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const receiverPattern = new RegExp(`\\b${escapedReceiver}\\b`);
const declaratorRegex = buildDeclaratorRegex(escapedReceiver);
for (let i = callLineIndex; i >= 0; i--) {
const line = lines[i];
if (!line || !receiverPattern.test(line)) continue;
const declaratorMatch = line.match(declaratorRegex);
if (declaratorMatch) {
const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
if (normalized) return normalized;
}
}
const headerCandidates = [
ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.h'),
ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hpp'),
ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hxx'),
].filter((candidate, index, arr) => arr.indexOf(candidate) === index && candidate !== ref.filePath);
for (const headerPath of headerCandidates) {
if (!context.fileExists(headerPath)) continue;
const headerSource = context.readFile(headerPath);
if (!headerSource) continue;
for (const line of headerSource.split(/\r?\n/)) {
if (!receiverPattern.test(line)) continue;
const declaratorMatch = line.match(declaratorRegex);
if (!declaratorMatch) continue;
const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
if (normalized) return normalized;
}
}
return null;
}
/**
* Try to resolve by method name on a class/object
*/
@@ -164,6 +280,23 @@ export function matchMethodCall(
const [, objectOrClass, methodName] = match;
if (ref.language === 'cpp' && dotMatch) {
const inferredType = inferCppReceiverType(objectOrClass!, ref, context);
if (inferredType) {
const typedMatch = resolveMethodOnType(
inferredType,
methodName!,
ref,
context,
0.9,
'instance-method',
);
if (typedMatch) {
return typedMatch;
}
}
}
// Strategy 1: Direct class name match (existing logic)
const classCandidates = context.getNodesByName(objectOrClass!);