diff --git a/CHANGELOG.md b/CHANGELOG.md index 981b8cf..a0551c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247) ## [1.4.1] - 2026-07-10 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 8619d12..d794707 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3823,6 +3823,75 @@ class APXCharacter { // the one real definition }); }); + describe('C++ explicit operator-call refs (#1247)', () => { + // tree-sitter-cpp can't parse an operator_name in field position: + // `a.operator+(b)` yields `call_expression(function: identifier «a», + // ERROR(operator_name), argument_list)` instead of a field_expression + // callee, so the emitted ref was just the receiver (`a`) and the call never + // resolved. The extractor recovers the operator_name from the ERROR child + // and emits `.operator+` like any other member call. + const HEADER = 'struct V {\n V operator+(const V& o) const;\n V operator[](int i) const;\n V operator()(int i) const;\n bool operator==(const V& o) const;\n int get() const;\n};\n'; + const callRefsOf = (body: string) => + extractFromSource('op.cpp', HEADER + body) + .unresolvedReferences.filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + + it('recovers receiver.operator+ from the explicit call form', () => { + expect(callRefsOf('V f(const V& a, const V& b) { return a.operator+(b); }\n')).toContain('a.operator+'); + }); + + it('recovers pointer receivers (p->operator+ → p.operator+)', () => { + expect(callRefsOf('V f(const V* p, const V& b) { return p->operator+(b); }\n')).toContain('p.operator+'); + }); + + it('recovers subscript, call, and comparison operator forms', () => { + const refs = callRefsOf( + 'V f1(const V& a) { return a.operator[](3); }\n' + + 'V f2(V& a) { return a.operator()(1); }\n' + + 'bool f3(const V& a, const V& b) { return a.operator==(b); }\n' + ); + expect(refs).toContain('a.operator[]'); + expect(refs).toContain('a.operator()'); + expect(refs).toContain('a.operator=='); + }); + + it('normalizes spaced call-site operator names to the compact definition form', () => { + // nlohmann/json calls `it.operator * ()` / `other.operator < (*this)` + // while defining `operator*` / `operator<` compact. + const refs = callRefsOf( + 'bool f(const V& a, const V& b) { return a.operator == (b); }\n' + + 'V g(const V& a) { return a.operator [] (3); }\n' + ); + expect(refs).toContain('a.operator=='); + expect(refs).toContain('a.operator[]'); + }); + + it('drops the ref for a complex receiver instead of guessing (no wrong edge)', () => { + // `object->operator[](val)` through a member chain ending in a call — + // the receiver type isn't inferable and a bare `operator[]` ref would + // let exact-name matching guess among unrelated operators. + const refs = callRefsOf( + 'struct W { V* obj(); };\n' + + 'V f(W& w, const V& b) { return w.obj()->operator+(b); }\n' + ); + expect(refs.some((r) => r.includes('operator+'))).toBe(false); + expect(refs).toContain('w.obj'); // the inner call itself still refs normally + }); + + it('emits the bare operator name for a this-> receiver', () => { + const refs = extractFromSource( + 'op.cpp', + 'struct V {\n V operator+(const V& o) const;\n V twice() const { return this->operator+(*this); }\n};\n' + ).unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(refs).toContain('operator+'); + expect(refs.some((r) => r.includes('this'))).toBe(false); + }); + + it('leaves plain member calls unchanged (control)', () => { + expect(callRefsOf('int f(const V& a) { return a.get(); }\n')).toContain('a.get'); + }); + }); + describe('C++ macro-prefixed function names (#1093 follow-up)', () => { // An unknown inline-specifier macro before the return type // (`FORCEINLINE FString GetName(…)`) threw tree-sitter into error recovery: diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 4d440e6..aa1bd72 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -3180,6 +3180,76 @@ void wrong() { WidgetFactory::create().onlyOther(); } }); }); + describe('C++ explicit operator-call resolution (#1247)', () => { + // `a.operator+(b)` produced no calls edge: the operator_name lands in an + // ERROR node (never a field_expression callee), so the extractor emitted a + // ref named just `a`. With the ERROR-node recovery it emits `a.operator+`, + // and matchMethodCall (dot pattern extended to admit operator method parts) + // resolves it through receiver-type inference. Infix `a + b` / `a[i]` need + // real type inference and are out of scope here (#1258). + async function indexCpp(files: Record): Promise { + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(tempDir, name), content); + } + cg = await CodeGraph.init(tempDir, { index: true }); + } + + function callerNamesOf(qualifiedName: string): string[] { + const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); + if (!target) return []; + const names = cg + .getIncomingEdges(target.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.source)?.name) + .filter((n): n is string => !!n); + return [...new Set(names)].sort(); + } + + it('resolves explicit operator calls to the receiver type, never a same-named decoy', async () => { + // Aaa sorts first and declares the same operators — only receiver-type + // inference (const V& a → V) can pick V, so a name-only tie can't win. + await indexCpp({ + 'optest.cpp': `struct Aaa { + Aaa operator+(const Aaa& o) const { return o; } + Aaa operator[](int i) const { return *this; } +}; +struct V { + int x; + V operator+(const V& o) const { return V{x + o.x}; } + V operator[](int i) const { return V{x + i}; } + int get() const { return x; } +}; +int plainCaller(const V& a) { return a.get(); } +V explicitCaller(const V& a, const V& b) { return a.operator+(b); } +V subscriptCaller(const V& a) { return a.operator[](3); } +V pointerCaller(const V* p, const V& b) { return p->operator+(b); } +`, + }); + + expect(callerNamesOf('V::operator+')).toEqual(['explicitCaller', 'pointerCaller']); + expect(callerNamesOf('V::operator[]')).toEqual(['subscriptCaller']); + expect(callerNamesOf('V::get')).toEqual(['plainCaller']); // control: plain calls unaffected + expect(callerNamesOf('Aaa::operator+')).toEqual([]); + expect(callerNamesOf('Aaa::operator[]')).toEqual([]); + }); + + it('resolves an out-of-line operator definition (declaration in header)', async () => { + await indexCpp({ + 'v.hpp': `#pragma once +struct V { int x; V operator+(const V& o) const; }; +`, + 'v.cpp': `#include "v.hpp" +V V::operator+(const V& o) const { return V{x + o.x}; } +`, + 'app.cpp': `#include "v.hpp" +V add(const V& a, const V& b) { return a.operator+(b); } +`, + }); + + expect(callerNamesOf('V::operator+')).toEqual(['add']); + }); + }); + describe('PHP chained static-factory call resolution (#608)', () => { function callerNamesOf(qualifiedName: string): string[] { const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName); diff --git a/__tests__/zz-scratch.test.ts b/__tests__/zz-scratch.test.ts new file mode 100644 index 0000000..2ec9686 --- /dev/null +++ b/__tests__/zz-scratch.test.ts @@ -0,0 +1,43 @@ +import { describe, it, beforeAll } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars, getParser } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +const CODE = `struct V { + int x; + V operator+(const V& o) const { return V{x + o.x}; } + V operator[](int i) const { return V{x + i}; } + int get() const { return x; } +}; + +int plainCaller(const V& a) { return a.get(); } +V explicitCaller(const V& a, const V& b) { return a.operator+(b); } +`; + +describe('scratch', () => { + it('dumps', async () => { + const parser: any = getParser('cpp' as any); + const tree = parser.parse(CODE); + const dump = (n: any, d = 0) => { + let out = `${' '.repeat(d)}${n.type} [${JSON.stringify(n.text.slice(0, 40))}]\n`; + for (let i = 0; i < n.childCount; i++) { + const c = n.child(i); + const f = n.fieldNameForChild ? n.fieldNameForChild(i) : null; + out += `${' '.repeat(d + 1)}${f ? f + ': ' : ''}`.trimEnd() ? '' : ''; + out += dump(c, d + 1); + } + return out; + }; + // just dump the explicitCaller subtree + console.log(dump(tree.rootNode)); + + const result = extractFromSource('optest.cpp', CODE); + console.log('NODES', result.nodes.map((n: any) => `${n.kind}:${n.name}`)); + console.log('KEYS', Object.keys(result)); + console.log('UREFS', JSON.stringify((result as any).unresolvedReferences, null, 1)); + }); +}); diff --git a/__tests__/zz-scratch2.test.ts b/__tests__/zz-scratch2.test.ts new file mode 100644 index 0000000..fbd4fee --- /dev/null +++ b/__tests__/zz-scratch2.test.ts @@ -0,0 +1,41 @@ +import { describe, it, beforeAll } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars, getParser } from '../src/extraction/grammars'; + +beforeAll(async () => { await initGrammars(); await loadAllGrammars(); }); + +const CASES: Record = { + dot_plus: 'V f(V a, V b) { return a.operator+(b); }', + arrow_plus: 'V f(V* a, V b) { return a->operator+(b); }', + dot_sub: 'V f(V a) { return a.operator[](3); }', + dot_call: 'V f(V a) { return a.operator()(3); }', + dot_eq: 'bool f(V a, V b) { return a.operator==(b); }', + dot_bool: 'bool f(V a) { return a.operator bool(); }', + qualified: 'V f(V a, V b) { return V::operator+(a, b); }', + free_op: 'V f(V a, V b) { return operator+(a, b); }', + this_op: 'struct V { V g(V b) { return this->operator+(b); } };', + member_op: 'struct V { V x; V g(V b) { return x.operator+(b); } };', + arrow_deref: 'V f(V a) { return a.operator->(); }', + dot_notop: 'bool f(V a) { return a.operator!(); }', +}; + +describe('dump', () => { + it('all', () => { + const p: any = getParser('cpp' as any); + for (const [k, code] of Object.entries(CASES)) { + const tree = p.parse(code); + const dump = (n: any, d = 0): string => { + let out = `${' '.repeat(d)}${n.type}${n.childCount === 0 ? ' ' + JSON.stringify(n.text) : ''}\n`; + for (let i = 0; i < n.childCount; i++) out += dump(n.child(i), d + 1); + return out; + }; + const call = (function find(n: any): any { + if (n.type === 'call_expression') return n; + for (let i = 0; i < n.childCount; i++) { const r = find(n.child(i)); if (r) return r; } + return null; + })(tree.rootNode); + const refs = extractFromSource('t.cpp', code).unresolvedReferences.filter((r: any) => r.referenceKind === 'calls'); + console.log(`\n=== ${k}: ${code}\n${call ? dump(call) : '(no call_expression)'}refs: ${JSON.stringify(refs.map((r: any) => r.referenceName))} hasError=${tree.rootNode.hasError}`); + } + }); +}); diff --git a/__tests__/zz-scratch3.test.ts b/__tests__/zz-scratch3.test.ts new file mode 100644 index 0000000..3ac0785 --- /dev/null +++ b/__tests__/zz-scratch3.test.ts @@ -0,0 +1,14 @@ +import { describe, it, beforeAll } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +beforeAll(async () => { await initGrammars(); await loadAllGrammars(); }); +describe('d', () => { it('n', () => { + const code = `struct V { + int x; + operator bool() const { return x != 0; } + V operator+(const V& o) const { return V{x+o.x}; } + V& operator=(const V& o) { x = o.x; return *this; } +};`; + const r = extractFromSource('t.cpp', code); + console.log('NODES', r.nodes.map((n: any) => `${n.kind}:${JSON.stringify(n.name)}`)); +}); }); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index e6520cc..fe3664a 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -4250,6 +4250,54 @@ export class TreeSitterExtractor { } else { const func = getChildByField(node, 'function') || node.namedChild(0); + // C++ explicit operator call `a.operator+(b)` / `p->operator+(b)` (#1247): + // tree-sitter-cpp can't parse an operator_name in field position, so the + // callee is NOT a field_expression — the call_expression carries + // `function: ` plus an ERROR child wrapping the operator_name. + // Reading the function field alone yields just the receiver (`a`), an + // unresolvable ref. Recover `.operator+` so it resolves like any + // other member call (matchMethodCall admits the operator method part). + // The infix forms `a + b` / `a[i]` need receiver type inference and are + // tracked separately (#1258). + if (this.language === 'cpp' && func) { + let operatorName = ''; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type !== 'ERROR') continue; + const op = child.namedChildren.find((c: SyntaxNode) => c.type === 'operator_name'); + if (op) { operatorName = getNodeText(op, this.source); break; } + } + if (operatorName) { + // Call sites may space the symbolic name (nlohmann/json's + // `it.operator * ()`, `other.operator < (*this)`) while definitions + // index compact (`operator*`) — normalize so they match. The word + // forms (`operator new`) keep their space. + const sym = operatorName.slice('operator'.length).trim(); + if (/^[^\w\s]/.test(sym)) operatorName = `operator${sym.replace(/\s+/g, '')}`; + // `->` receivers resolve identically to `.` ones. A receiver that + // isn't a simple identifier/member chain (`(*it)`, a call result, …) + // can't aid type inference, and a bare operator name would fall + // through to exact-name matching — which GUESSES among the many + // same-named operators (on nlohmann/json it linked a std::map + // `object->operator[]` call to an unrelated in-repo operator[]). + // Drop the ref: a silent miss, never a wrong edge. `this->` keeps + // the bare name, matching how `this.method()` calls are emitted — + // the target is on the enclosing class, where exact-name's same-file + // preference is reliable. + const receiver = getNodeText(func, this.source).replace(/->/g, '.').replace(/\s+/g, ''); + if (receiver !== 'this' && !/^[A-Za-z_][\w.]*$/.test(receiver)) return; + const calleeName = receiver === 'this' ? operatorName : `${receiver}.${operatorName}`; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: calleeName, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + return; + } + } + if (func) { 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() diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 075bb10..91ddd1a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1459,7 +1459,18 @@ export function matchMethodCall( // (with its existing single-candidate / receiver-overlap guards). Without this // a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`, // `Guard.Against.X()`) matched no pattern and never resolved. - const dotMatch = ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/); + // C++ explicit operator call `a.operator+(b)` reaches the resolver as + // `a.operator+` (#1247) — the operator's symbol chars (`+`, `==`, `[]`, `()`) + // fail the \w method part of the plain pattern, so admit them explicitly. + // Names like `operatorTable` stay on the plain pattern (tried first); the + // operator form requires at least one non-word char after `operator`, and + // every downstream strategy compares the method part by exact string + // equality, so a stray match can't invent an edge. + const dotMatch = + ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ?? + (ref.language === 'cpp' + ? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/) + : null); const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/); // Lua/Luau method calls use a single colon (`lg:log`); R uses `$` (`lg$log`). // Recognize these receiver/method separators so local-variable receiver-type