mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
Indexing or syncing a C++ project stopped at "Resolving refs" with
"Invalid regular expression: /\boperator++\s*(?:<[^<>()]*>)?\s*\(/:
Nothing to repeat" as soon as its code called an overloaded operator by
name, as `this->operator++();` inside `operator++(int)` does. Choosing a
method's overload reads the argument list after the name, and
cppParenListIn and cppOverloadParameters wrote the name into that
pattern with only `~` escaped. `operator++` did not compile. `operator()`
matched the name's own empty parentheses as the arguments, `operator[]`
never matched, and `operator+=` matched the next `operator=`. The same
helper serves every language that overloads by arity, so Scala `++`
overloads and Kotlin names in backticks that hold a `(` failed the same
way.
The fix adds one escape helper, escapeRegExp in src/utils.ts, the same
body as the private copies elsewhere. It is used at every place in src/
where a node name, a reference name, a piece of one, or a word read
from source goes into a RegExp unchecked: 114 interpolations in 13
files. 79 were raw, 31 escaped only `$`, 1 `$` and `\`, 1 only `.`, and
the 2 overload readers only `~`. An AST pass over all 490 dynamic
RegExp constructions followed each interpolated value to its sources.
The 72 it still flags were each read by hand. They are
identifier-checked first, built from escaped names, constants, or the
CLI/MCP glob converters. The kernel's one dynamic regex already
escapes.
With operator arguments now read, nlohmann/json's `p->operator[](0)`
reached the argument-type scorer and picked `template <typename T>
operator[](T* key)`: a `size_type` parameter scored as an unknown
class, a `0` as a null pointer. `size_type` and `difference_type` now
count as numbers, as `size_t` does.
Validation:
- New __tests__/operator-method-names.test.ts (5 tests: init and sync of
an operator fixture, operator() and operator+= overload choice, the
json operator[](0) shape, Scala and Kotlin). With main's sources,
4 fail (3 throw "Nothing to repeat"); the json shape fails only with
size_type taken out of the number types. All pass on the kernel and
with CODEGRAPH_KERNEL=0.
- Census with a preload that logs every throwing RegExp: main threw
only in cppParenListIn (CTRE 27, folly 3); the fix threw 0 times in
all 12 repos below.
- A/B on main efff7457, graph dumps:
- fmt, glm, rocksdb, protobuf: byte-identical.
- json: 9 calls retargeted, all right (`j.at("name")` to the key
overload, `v.at(i)` to size_type).
- abseil-cpp: 3 retargeted, all right (operator() self-call to the
two-argument overload, assign(3, 10) to the count overload).
- CTRE and folly crash on main; both index now. CTRE: 10 of 12 calls
retargeted against main's census run are right; 2 single-header
calls reach the identical copy in include/. folly: 4 of 6 are right;
2 small_vector assign(int, int) calls link the count overload where
C++ takes the unconstrained template.
- Controls gin (Go), bulletproof-react (TS), ktlint (Kotlin, 86
backtick names with pattern characters) and cats (Scala symbolic
methods): byte-identical.
- Cost: none measurable. Medians of 3 interleaved init runs: fmt -1.5%,
json +2.1%, glm +0.2%, abseil -2.5%, gin -2.1%, bulletproof-react
-0.3%, rocksdb +0.4% (45.2 s vs 45.3 s).
- kernel-parity on fmt + json (cpp/c): 458/669 at parity, 0 diffs,
both arms.
Fixes #2457
Fixes #2482
Fixes #2636
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
/**
|
|
* A method whose name is not a plain word: C++'s `operator++` and
|
|
* `operator()`, Scala's `++`, a Kotlin name written in backticks. The
|
|
* resolver writes names into the patterns it reads source with, and these
|
|
* went in as pattern syntax:
|
|
*
|
|
* - `operator++` is "Nothing to repeat". Choosing between a method's
|
|
* overloads reads the call's argument list after its name, so one
|
|
* `this->operator++();` inside `operator++(int)` stopped a whole index,
|
|
* and a sync after an edit, at "Resolving refs" (#2457, #2482, #2636).
|
|
* - `operator()` matched the empty `()` of its own name, so a call to the
|
|
* two-argument overload read as one with no arguments and stayed linked
|
|
* to the overload it was written in.
|
|
*/
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import CodeGraph from '../src/index';
|
|
import type { Node } from '../src/types';
|
|
|
|
const lines = (...l: string[]): string => [...l, ''].join('\n');
|
|
|
|
/** The 1-based line of `text` that contains `needle` (exactly one must). */
|
|
const lineOf = (text: string, needle: string): number => {
|
|
const at = text.split('\n').map((l, i) => (l.includes(needle) ? i + 1 : 0)).filter(Boolean);
|
|
if (at.length !== 1) throw new Error(`${JSON.stringify(needle)} is on ${at.length} lines`);
|
|
return at[0]!;
|
|
};
|
|
|
|
const COUNTER_HPP = lines(
|
|
'#pragma once',
|
|
'#include <cstddef>',
|
|
'#include <compare>',
|
|
'',
|
|
'struct Counter {',
|
|
' int value = 0;',
|
|
' Counter operator++(int) {',
|
|
' Counter before = *this;',
|
|
' this->operator++();',
|
|
' return before;',
|
|
' }',
|
|
' Counter& operator++() {',
|
|
' ++value;',
|
|
' return *this;',
|
|
' }',
|
|
' Counter& operator+=(int n) {',
|
|
' value += n;',
|
|
' return *this;',
|
|
' }',
|
|
' Counter& operator+=(const Counter& other) {',
|
|
' value += other.value;',
|
|
' return *this;',
|
|
' }',
|
|
' auto operator<=>(const Counter& other) const = default;',
|
|
' explicit operator bool() const { return value != 0; }',
|
|
'};',
|
|
'',
|
|
'struct Scale {',
|
|
' int operator()(int x) {',
|
|
' return this->operator()(x, 2);',
|
|
' }',
|
|
' int operator()(int x, int factor) {',
|
|
' return x * factor;',
|
|
' }',
|
|
'};',
|
|
'',
|
|
'struct Table {',
|
|
' int cells[8] = {};',
|
|
' int& operator[](std::size_t i) { return cells[i]; }',
|
|
'};',
|
|
'',
|
|
'struct Pointee {',
|
|
' int field = 0;',
|
|
' int read() const { return field; }',
|
|
'};',
|
|
'',
|
|
'struct Handle {',
|
|
' Pointee* target = nullptr;',
|
|
' Pointee* operator->() const { return target; }',
|
|
' int& operator->*(int Pointee::*m) const { return target->*m; }',
|
|
'};',
|
|
'',
|
|
'struct Pool {',
|
|
' static void* operator new[](std::size_t n);',
|
|
' static void operator delete(void* p);',
|
|
' static void operator delete[](void* p);',
|
|
' static void* grab(std::size_t n) { return Pool::operator new[](n); }',
|
|
' static void drop(void* p) { Pool::operator delete(p); }',
|
|
'};',
|
|
'',
|
|
'struct Distance { long double km; };',
|
|
'Distance operator"" _km(long double v);',
|
|
);
|
|
|
|
const COUNTER_CPP = lines(
|
|
'#include "counter.hpp"',
|
|
'#include <cstdlib>',
|
|
'',
|
|
'void* Pool::operator new[](std::size_t n) { return std::malloc(n); }',
|
|
'void Pool::operator delete(void* p) { std::free(p); }',
|
|
'void Pool::operator delete[](void* p) { Pool::operator delete(p); }',
|
|
'Distance operator"" _km(long double v) { return Distance{v}; }',
|
|
'',
|
|
'int useAll() {',
|
|
' Counter c;',
|
|
' c.operator++();',
|
|
' c.operator++(0);',
|
|
' c.operator+=(3);',
|
|
' Counter d;',
|
|
' d.operator+=(c);',
|
|
' bool nonzero = c.operator bool();',
|
|
' auto order = c.operator<=>(d);',
|
|
' (void)order;',
|
|
' Scale s;',
|
|
' int scaled = s.operator()(4);',
|
|
' Table t;',
|
|
' t.operator[](1) = 7;',
|
|
' Pointee pointee;',
|
|
' Handle h{&pointee};',
|
|
' int got = h.operator->()->read() + h.operator->*(&Pointee::field);',
|
|
' void* arr = Pool::operator new[](32);',
|
|
' Pool::operator delete[](arr);',
|
|
' Distance near = operator"" _km(2.0L);',
|
|
' return scaled + got + (nonzero ? 1 : 0) + static_cast<int>(near.km);',
|
|
'}',
|
|
);
|
|
|
|
describe('a method whose name is not a plain word', () => {
|
|
const roots: string[] = [];
|
|
const graphs: CodeGraph[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const cg of graphs.splice(0)) cg.close();
|
|
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
const project = (files: Record<string, string>): string => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-operator-names-'));
|
|
roots.push(root);
|
|
for (const [rel, text] of Object.entries(files)) {
|
|
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
fs.writeFileSync(path.join(root, rel), text);
|
|
}
|
|
return root;
|
|
};
|
|
|
|
const index = async (root: string): Promise<CodeGraph> => {
|
|
const cg = await CodeGraph.init(root, { index: true });
|
|
graphs.push(cg);
|
|
return cg;
|
|
};
|
|
|
|
/** `<qualified name>@<file>:<line>` of each call from the node at `file`:`line`. */
|
|
const callsFrom = (cg: CodeGraph, file: string, line: number): string[] => {
|
|
const from = cg.getNodesInFile(file).find((n: Node) => n.startLine === line && (n.kind === 'method' || n.kind === 'function'));
|
|
expect(from, `${file}:${line}`).toBeDefined();
|
|
return cg.getCallees(from!.id).filter((r) => r.edge.kind === 'calls')
|
|
.map((r) => `${r.node.qualifiedName}@${path.basename(r.node.filePath)}:${r.node.startLine}`)
|
|
.sort();
|
|
};
|
|
|
|
const hpp = (needle: string): string => `counter.hpp:${lineOf(COUNTER_HPP, needle)}`;
|
|
const cpp = (needle: string): string => `counter.cpp:${lineOf(COUNTER_CPP, needle)}`;
|
|
|
|
it('C++: an index of overloaded operators that call each other by name finishes, and each call reaches its overload', async () => {
|
|
const cg = await index(project({ 'src/counter.hpp': COUNTER_HPP, 'src/counter.cpp': COUNTER_CPP }));
|
|
|
|
// `this->operator++();` inside the postfix `operator++(int)`: the prefix one.
|
|
expect(callsFrom(cg, 'src/counter.hpp', lineOf(COUNTER_HPP, 'Counter operator++(int)')))
|
|
.toEqual([`Counter::operator++@${hpp('Counter& operator++()')}`]);
|
|
// `this->operator()(x, 2)` inside the one-argument `operator()`: the two-argument one.
|
|
expect(callsFrom(cg, 'src/counter.hpp', lineOf(COUNTER_HPP, 'int operator()(int x) {')))
|
|
.toEqual([`Scale::operator()@${hpp('int operator()(int x, int factor)')}`]);
|
|
expect(callsFrom(cg, 'src/counter.hpp', lineOf(COUNTER_HPP, 'static void* grab')))
|
|
.toEqual([`Pool::operator new[]@${cpp('void* Pool::operator new[]')}`]);
|
|
expect(callsFrom(cg, 'src/counter.hpp', lineOf(COUNTER_HPP, 'static void drop')))
|
|
.toEqual([`Pool::operator delete@${cpp('void Pool::operator delete(void* p)')}`]);
|
|
|
|
const useAll = callsFrom(cg, 'src/counter.cpp', lineOf(COUNTER_CPP, 'int useAll()'));
|
|
expect(useAll).toEqual(expect.arrayContaining([
|
|
`Counter::operator++@${hpp('Counter& operator++()')}`,
|
|
`Counter::operator++@${hpp('Counter operator++(int)')}`,
|
|
`Counter::operator+=@${hpp('Counter& operator+=(int n)')}`,
|
|
`Counter::operator+=@${hpp('Counter& operator+=(const Counter& other)')}`,
|
|
`Counter::operator<=>@${hpp('auto operator<=>')}`,
|
|
`Scale::operator()@${hpp('int operator()(int x) {')}`,
|
|
`Table::operator[]@${hpp('int& operator[]')}`,
|
|
`Handle::operator->@${hpp('Pointee* operator->()')}`,
|
|
`Handle::operator->*@${hpp('int& operator->*')}`,
|
|
`Pool::operator new[]@${cpp('void* Pool::operator new[]')}`,
|
|
`Pool::operator delete[]@${cpp('void Pool::operator delete[]')}`,
|
|
`operator"" _km@${cpp('Distance operator"" _km(long double v) {')}`,
|
|
]));
|
|
}, 120_000);
|
|
|
|
it('C++: a sync that adds such calls to an indexed project finishes, and links them', async () => {
|
|
// Indexed first without a single call of `operator++` by its name.
|
|
const strip = (text: string): string => text
|
|
.replace(' this->operator++();\n', ' ++value;\n')
|
|
.replace(' c.operator++();\n', '')
|
|
.replace(' c.operator++(0);\n', '');
|
|
const root = project({ 'src/counter.hpp': strip(COUNTER_HPP), 'src/counter.cpp': strip(COUNTER_CPP) });
|
|
const cg = await index(root);
|
|
expect(callsFrom(cg, 'src/counter.hpp', lineOf(COUNTER_HPP, 'Counter operator++(int)'))).toEqual([]);
|
|
|
|
fs.writeFileSync(path.join(root, 'src/counter.hpp'), COUNTER_HPP);
|
|
fs.writeFileSync(path.join(root, 'src/counter.cpp'), COUNTER_CPP);
|
|
await cg.sync();
|
|
|
|
expect(callsFrom(cg, 'src/counter.hpp', lineOf(COUNTER_HPP, 'Counter operator++(int)')))
|
|
.toEqual([`Counter::operator++@${hpp('Counter& operator++()')}`]);
|
|
expect(callsFrom(cg, 'src/counter.cpp', lineOf(COUNTER_CPP, 'int useAll()'))).toEqual(expect.arrayContaining([
|
|
`Counter::operator++@${hpp('Counter& operator++()')}`,
|
|
`Counter::operator++@${hpp('Counter operator++(int)')}`,
|
|
]));
|
|
}, 120_000);
|
|
|
|
it('C++: the argument list of an `operator()` call is the one after the name, not the name\'s own parentheses', async () => {
|
|
// No `operator++` here: nothing threw before, the calls just went to the wrong overloads.
|
|
const hppText = lines(
|
|
'struct Scale {',
|
|
' int operator()(int x) { return this->operator()(x, 2); }',
|
|
' int operator()(int x, int factor) { return x * factor; }',
|
|
'};',
|
|
'struct Total {',
|
|
' int value = 0;',
|
|
' Total& operator+=(int n) { value += n; return *this; }',
|
|
' Total& operator+=(const Total& other) { value += other.value; return *this; }',
|
|
'};',
|
|
);
|
|
const cppText = lines(
|
|
'#include "scale.hpp"',
|
|
'int addUp() {',
|
|
' Total a;',
|
|
' Total b;',
|
|
' b.operator+=(a);',
|
|
' return b.value;',
|
|
'}',
|
|
);
|
|
const cg = await index(project({ 'src/scale.hpp': hppText, 'src/scale.cpp': cppText }));
|
|
expect(callsFrom(cg, 'src/scale.hpp', lineOf(hppText, 'int operator()(int x) {')))
|
|
.toEqual([`Scale::operator()@scale.hpp:${lineOf(hppText, 'int operator()(int x, int factor)')}`]);
|
|
expect(callsFrom(cg, 'src/scale.cpp', lineOf(cppText, 'int addUp()')))
|
|
.toEqual([`Total::operator+=@scale.hpp:${lineOf(hppText, 'Total& operator+=(const Total& other)')}`]);
|
|
}, 120_000);
|
|
|
|
it('C++: `operator[](0)` still reaches the `size_type` overload, now that its arguments are read', async () => {
|
|
// nlohmann/json's `p = &p->operator[](0);`: the index overload, as before, not the C-string key one.
|
|
// A container's `size_type` is a number, which a `0` fits better than it fits a pointer.
|
|
const hppText = lines(
|
|
'struct Json {',
|
|
' using size_type = unsigned long;',
|
|
' Json& operator[](size_type idx) { return *this; }',
|
|
' template <typename T>',
|
|
' JSON_NON_NULL(2)',
|
|
' Json& operator[](T* key) { return *this; }',
|
|
'};',
|
|
'Json* walk(Json* p) { return &p->operator[](0); }',
|
|
);
|
|
const cg = await index(project({ 'src/json.hpp': hppText }));
|
|
expect(callsFrom(cg, 'src/json.hpp', lineOf(hppText, 'Json* walk(')))
|
|
.toEqual([`Json::operator[]@json.hpp:${lineOf(hppText, 'operator[](size_type idx)')}`]);
|
|
}, 120_000);
|
|
|
|
it('Scala and Kotlin: overloads named `++` or in backticks no longer stop the index', async () => {
|
|
const scala = lines(
|
|
'class Vec(val x: Int) {',
|
|
' def ++(o: Vec): Vec = new Vec(x + o.x)',
|
|
' def ++(n: Int, m: Int): Vec = this.++(new Vec(n + m))',
|
|
' def twice: Vec = this.++(1, 2)',
|
|
'}',
|
|
);
|
|
const kotlin = lines(
|
|
'class Weird {',
|
|
' fun `reads (a value`(x: Int): Int = x',
|
|
' fun `reads (a value`(x: Int, y: Int): Int = `reads (a value`(x + y)',
|
|
' fun use(): Int = `reads (a value`(1, 2)',
|
|
'}',
|
|
);
|
|
const cg = await index(project({ 'src/Vec.scala': scala, 'src/Weird.kt': kotlin }));
|
|
expect(callsFrom(cg, 'src/Vec.scala', lineOf(scala, 'def twice'))).toEqual([`Vec::++@Vec.scala:${lineOf(scala, 'def ++(n: Int, m: Int)')}`]);
|
|
expect(callsFrom(cg, 'src/Weird.kt', lineOf(kotlin, 'fun use()'))).toEqual([`Weird::\`reads (a value\`@Weird.kt:${lineOf(kotlin, 'x: Int, y: Int')}`]);
|
|
}, 120_000);
|
|
});
|