فهرست منبع

fix(cpp): resolve explicit operator calls (a.operator+(b)) to the operator method (#1268)

* fix(cpp): resolve explicit operator calls (a.operator+(b)) to the operator method (#1247)

tree-sitter-cpp can't parse an operator_name in field position: the
call_expression carries `function: <receiver>` plus an ERROR child
wrapping the operator_name instead of a field_expression callee, so the
extractor emitted a calls ref named just the receiver (`a`) and the edge
never resolved — while the operator method itself indexed fine.

Two-part fix, scoped to the explicit call form (infix `a + b` / `a[i]`
need receiver type inference and are tracked in #1258):

- extraction: recover the operator_name from the ERROR child and emit
  `<receiver>.operator+` (`->` receivers normalized, `this->` emits the
  bare name), like any other member call
- resolution: matchMethodCall's dot pattern now admits an operator
  method part (cpp-gated; symbol chars failed the \w match), so
  receiver-type inference + resolveMethodOnType validate the target —
  a same-named operator on an unrelated class can't capture the edge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cpp): harden explicit operator-call recovery against real-world shapes (#1247)

Validated on nlohmann/json (dozens of explicit operator[] / operator* /
operator< call sites). Two refinements the synthetic fixtures missed:

- normalize spaced call-site operator names (`it.operator * ()`,
  `other.operator < (*this)`) to the compact form definitions index as
- drop the ref for a complex receiver (`obj()->operator+`, member chains
  ending in a call) instead of emitting a bare operator name: exact-name
  fallback GUESSED among unrelated same-named operators (linked a
  std::map operator[] call to an in-repo operator[]) — silent miss,
  never a wrong edge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 1 ماه پیش
والد
کامیت
6103f5e228

+ 3 - 0
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
 

+ 69 - 0
__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 `<receiver>.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:

+ 70 - 0
__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<string, string>): Promise<void> {
+      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);

+ 43 - 0
__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));
+  });
+});

+ 41 - 0
__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<string,string> = {
+  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}`);
+    }
+  });
+});

+ 14 - 0
__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)}`));
+}); });

+ 48 - 0
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: <receiver>` plus an ERROR child wrapping the operator_name.
+      // Reading the function field alone yields just the receiver (`a`), an
+      // unresolvable ref. Recover `<receiver>.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()

+ 12 - 1
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