From fedda29790d0c9a558ba69d233371f9aaede3383 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:40:06 +0000 Subject: [PATCH 01/29] fix(cpp): a declaration with a qualifier or annotation around its name gives its type to calls on it (#2536) Receiver inference reads a C++ declaration as a type followed by the name. What may stand between them was read as the type: in rocksdb's `Iterator* const iter = iter_guard.get();` the type was `const`, which names nothing, so the scan went on to an earlier function's `iter`, and in protobuf's `io::EpsCopyOutputStream* PROTOBUF_NONNULL stream` the type was a class named `PROTOBUF_NONNULL`. A name an annotation follows (leveldb's `MemTable* imm_ GUARDED_BY(mutex_);`) was not read at all. - The declarator regex (and the range-for type tail) reads past a cv- qualifier or `__restrict` before or after a `*` or `&` (`T* const`, `T const&`), the nullability annotations after one (abseil's and clang's, and the ALL-CAPS macros c-cpp.ts already blanks for the parser), and an ALL-CAPS annotation macro's call after the name. - matchCppDeclarator hands the readers the type without those annotations (cppDeclaratorType); cv-qualifiers stay, since what reads a type reads past them. An ALL-CAPS word counts as an annotation only after a `*` or `&`, so a type's own name stays (`OS_HANDLE* const h`), and template arguments are kept as written. - The call's own file, its same-named header, a class's members and a range-for's variable are read that way; the local-name check for bare calls reads past the qualifiers too. - #2533's test that a method's parameter is no member used `T* const` locals, which the scan couldn't read; it now declares no local, since one the scan reads decides first. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-member-receiver.test.ts | 6 +- .../cpp-qualified-declarator-receiver.test.ts | 438 ++++++++++++++++++ src/resolution/name-matcher.ts | 78 +++- 4 files changed, 512 insertions(+), 11 deletions(-) create mode 100644 __tests__/cpp-qualified-declarator-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01aa134aea..4aca5fb6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, text inside a string literal is no longer taken for a variable's declaration. That covers the C code protocolbuffers/protobuf's upb generator writes in a raw string (`R"cc( … upb_MiniTableField field = $field_init$; … )cc"`) and log messages like facebook/rocksdb's `"… in WriteBatch handler; …"`. Before, a later call like `field.ctype()` on the function's own `upb::FieldDefPtr field` parameter, or `handler->PutCF(…)` on a `WriteBatch::Handler* handler`, was matched against the type that text seemed to name, so it linked to nothing or to another class's method. Re-index C++ projects after upgrading. - In C++, a call on an `auto` variable now links to the method of the class C++ finds for the type its initializer gives it, as a call on a variable declared with that type does. The type is read from a cast or smart pointer written out, like facebook/rocksdb's `auto iter = std::unique_ptr(…)` or `auto* mutex = static_cast(arg)`, from `new` or `std::make_unique` of a class, and from what a function or method is declared to return, like the `Iterator*` of `txn->GetIterator(…)`, read to the end of an initializer that goes on to the next line. Before, such a variable took the type of an earlier variable of the same name, or none: in RocksDB, `iter->SeekToFirst()` reached `MemTableRep::Iterator` and `mutex->Lock()` reached `port::Mutex`. A chained call like `cfd->current()->storage_info()` now looks up the class the inner call is declared to return the same way, through a smart pointer too, so protocolbuffers/protobuf's Java generator no longer reaches the C# generator's `EnumGenerator`. A type from outside the project no longer links to a class of the project that shares its name: protobuf's `status.ok()` on an `absl::Status` reached `upb::Status::ok`, and fmtlib/fmt's `os.flush()` on a `std::ostream&` reached fmt's own `ostream::flush`. Re-index C++ projects after upgrading. - In C++, a call on a data member of the calling method's class, like `versions_->GetColumnFamilySet()` in facebook/rocksdb's `db_impl_write.cc`, now links to the method of the member's type wherever the class is declared. Before, only the call's own file and the header of the same name were read for the member, and RocksDB declares `DBImpl` in `db_impl.h` while its methods are spread over `db_impl_write.cc`, `db_impl_compaction_flush.cc`, `db_impl_open.cc` and more, so such calls were guessed by the member's name. A member declared below an inline method that uses it, one a base class declares, and, in a gtest `TEST_F` or `TEST_P` body, one of the test's fixture, like RocksDB's `db_`, are found the same way, and a member of a class nested in the caller's class is no longer taken for the caller's own. A call on a member of a standard-library type, like `std::atomic shutting_down_`, no longer links to a project method that only shares the method's name. Re-index C++ projects after upgrading. +- In C++, a variable, parameter or member whose declaration puts a qualifier or an annotation around its name is now read for its type, so calls on it link to the declared class's methods: a `const` after the `*` or `&`, like facebook/rocksdb's `Iterator* const iter = iter_guard.get();` or google/leveldb's `Env* const env_;`, a type written before its `const`, like `Slice const& key`, a nullability annotation after the `*`, like protocolbuffers/protobuf's `io::EpsCopyOutputStream* PROTOBUF_NONNULL stream`, and a thread-safety annotation after the name, like LevelDB's `MemTable* imm_ GUARDED_BY(mutex_);`. Before, such a declaration was skipped, or the annotation was taken for its type, so the call took the type of an earlier variable of the same name or was guessed by the method's name: RocksDB's `iter->SeekToFirst()` reached the unrelated `ArenaWrappedDBIter`, and LevelDB's `env_->NewWritableFile(…)` reached `InMemoryEnv`. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-member-receiver.test.ts b/__tests__/cpp-member-receiver.test.ts index 5fe0408bc0..762ccb9457 100644 --- a/__tests__/cpp-member-receiver.test.ts +++ b/__tests__/cpp-member-receiver.test.ts @@ -401,8 +401,8 @@ describe('what keeps the call as it was', () => { it('a parameter of a method the class declares, which is no member', async () => { // rocksdb's DB declares `CreateColumnFamilies(const std::vector<…>& - // column_families, …)`; DBImpl's `const ColumnFamilySet* const - // column_families` is a local the scan cannot read. + // column_families, …)`. (The function declares no local of the names: + // one the scan reads would decide first.) const cg = await indexed({ ...ROCKSDB, 'db/db_impl/db_impl.h': ROCKSDB['db/db_impl/db_impl.h'].replace( @@ -413,8 +413,6 @@ describe('what keeps the call as it was', () => { '#include "db/db_impl/db_impl.h"', 'namespace rocksdb {', 'int DBImpl::CountColumnFamilies() {', - ' const ColumnFamilySet* const column_families = versions_->GetColumnFamilySet();', - ' const Statistics* const stats = nullptr;', ' if (stats->load()) return 0;', ' return column_families->NumberOfColumnFamilies();', '}', diff --git a/__tests__/cpp-qualified-declarator-receiver.test.ts b/__tests__/cpp-qualified-declarator-receiver.test.ts new file mode 100644 index 0000000000..817848f81e --- /dev/null +++ b/__tests__/cpp-qualified-declarator-receiver.test.ts @@ -0,0 +1,438 @@ +/** + * A C++ call on a variable, parameter or member whose declaration puts + * qualifiers or annotations around its name reaches the method of the + * declared type. + * + * Receiver inference reads a declaration as a type followed by the name. + * What may stand between them was read as the type: in facebook/rocksdb's + * `Iterator* const iter = iter_guard.get();` the type was `const`, which + * names nothing, so the scan went on to an earlier function's `iter` and + * guessed by its type's last name. The same held for a type written before + * its `const` (`Slice const& key`), for protobuf's nullability annotations + * (`Arena* PROTOBUF_NULLABLE arena`, read as a type named + * `PROTOBUF_NULLABLE`), and for an annotation after the name (leveldb's + * `VersionSet* const versions_ GUARDED_BY(mutex_);`, not read at all). + * + * A guess can land on the right method by a shared word, so these tests + * check the confidence, not only the target. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-qualified-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName @confidence`, one per call site. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** rocksdb's two `Iterator`s: the public one and the nested `MemTableRep::Iterator`. */ +const ITERATORS = lines( + 'namespace rocksdb {', + 'class Iterator {', + ' public:', + ' virtual ~Iterator() {}', + ' virtual void SeekToFirst() = 0;', + ' virtual bool Valid() const = 0;', + '};', + 'class MemTableRep {', + ' public:', + ' class Iterator {', + ' public:', + ' void SeekToFirst() {}', + ' bool Valid() const { return false; }', + ' };', + '};', + '} // namespace rocksdb', +); + +/** A class the calls are in the namespace of, declared in a header. */ +const UI_WIDGET = lines( + 'namespace ui {', + 'class Widget {', + ' public:', + ' void Draw() {}', + '};', + '} // namespace ui', +); + +/** A namesake beside the calls, which a guess by the type's last name prefers. */ +const LEGACY_WIDGET = lines( + 'namespace legacy {', + 'class Widget {', + ' public:', + ' void Draw() {}', + '};', + '} // namespace legacy', +); + +describe('C++ declarations with qualifiers or annotations around the name', () => { + it('reads `Iterator* const iter`, not an earlier function\'s `iter`', async () => { + const cg = await indexed({ + 'include/iterator.h': lines( + 'namespace rocksdb {', + 'class Iterator {', + ' public:', + ' virtual ~Iterator() {}', + ' virtual void SeekToFirst() = 0;', + ' virtual bool Valid() const = 0;', + '};', + '} // namespace rocksdb', + ), + // As in rocksdb's memtable.cc, the nested namesake sits beside the calls. + 'db/memtable.cc': lines( + '#include "include/iterator.h"', + 'namespace rocksdb {', + 'class MemTableRep {', + ' public:', + ' class Iterator {', + ' public:', + ' void SeekToFirst() {}', + ' bool Valid() const { return false; }', + ' };', + '};', + 'void Earlier() {', + ' MemTableRep::Iterator* iter = nullptr;', + ' iter->SeekToFirst();', + '}', + 'void Scan(Iterator* source) {', + ' Iterator* const iter = source;', + ' iter->SeekToFirst();', + ' if (iter->Valid()) {}', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Scan')).toEqual(['rocksdb::Iterator::SeekToFirst @0.9', 'rocksdb::Iterator::Valid @0.9']); + } finally { + cg.close(); + } + }); + + it('reads a type written before its `const` (`Slice const& key`)', async () => { + const cg = await indexed({ + 'util/slice.h': lines( + 'namespace leveldb {', + 'class Slice {', + ' public:', + ' int size() const { return 0; }', + '};', + 'namespace log {', + 'class Slice {', + ' public:', + ' int size() const { return 0; }', + '};', + '} // namespace log', + '} // namespace leveldb', + ), + 'util/length.cc': lines( + '#include "util/slice.h"', + 'namespace leveldb {', + 'int Length(Slice const& key) {', + ' return key.size();', + '}', + 'int Count(Slice const* key) {', + ' return key->size();', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::Length')).toEqual(['leveldb::Slice::size @0.9']); + expect(calls(cg, 'leveldb::Count')).toEqual(['leveldb::Slice::size @0.9']); + } finally { + cg.close(); + } + }); + + it('reads past a nullability annotation after the `*` (`Arena* PROTOBUF_NULLABLE arena`)', async () => { + const cg = await indexed({ + 'src/google/protobuf/arena.h': lines( + '#define PROTOBUF_NULLABLE', + '#define PROTOBUF_NONNULL', + 'namespace google {', + 'namespace protobuf {', + 'class Arena {', + ' public:', + ' void Reset() {}', + '};', + 'class ArenaStringPtr {', + ' public:', + ' void Reset() {}', + '};', + 'void Clear(Arena* PROTOBUF_NULLABLE arena) {', + ' arena->Reset();', + '}', + 'void ClearOwned(Arena* owner) {', + ' Arena* absl_nonnull arena = owner;', + ' arena->Reset();', + '}', + 'class Printer {', + ' public:', + ' void Print() {}', + '};', + 'class Generator {', + ' public:', + ' void Emit();', + ' private:', + ' Printer* PROTOBUF_NONNULL printer_;', + '};', + '} // namespace protobuf', + '} // namespace google', + ), + // A member read from its class, in a file of another name. + 'src/google/protobuf/generator_emit.cc': lines( + '#include "src/google/protobuf/arena.h"', + 'namespace google {', + 'namespace protobuf {', + 'class Formatter {', + ' public:', + ' void Print() {}', + '};', + 'void Generator::Emit() {', + ' printer_->Print();', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::Clear')).toEqual(['google::protobuf::Arena::Reset @0.9']); + expect(calls(cg, 'google::protobuf::ClearOwned')).toEqual(['google::protobuf::Arena::Reset @0.9']); + expect(calls(cg, 'google::protobuf::Generator::Emit')).toEqual(['google::protobuf::Printer::Print @0.9']); + } finally { + cg.close(); + } + }); + + it('reads a member with an annotation after its name (`VersionSet* const versions_ GUARDED_BY(mutex_);`)', async () => { + const cg = await indexed({ + 'db/version_set.h': lines( + 'namespace leveldb {', + 'class VersionSet {', + ' public:', + ' int LogNumber() const { return 0; }', + '};', + 'class VersionEdit {', + ' public:', + ' int LogNumber() const { return 0; }', + '};', + '} // namespace leveldb', + ), + 'db/db_impl.h': lines( + '#include "db/version_set.h"', + '#define GUARDED_BY(x)', + 'namespace leveldb {', + 'class DBImpl {', + ' public:', + ' void Recover();', + ' private:', + ' VersionSet* const versions_ GUARDED_BY(mutex_);', + '};', + '} // namespace leveldb', + ), + 'db/db_impl_open.cc': lines( + '#include "db/db_impl.h"', + 'namespace leveldb {', + 'void DBImpl::Recover() {', + ' versions_->LogNumber();', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::DBImpl::Recover')).toEqual(['leveldb::VersionSet::LogNumber @0.9']); + } finally { + cg.close(); + } + }); + + it('reads `auto* const` and smart-pointer locals, and range-for variables', async () => { + const cg = await indexed({ + 'ui/widget.h': UI_WIDGET, + 'ui/paint.cc': lines( + '#include ', + '#include ', + '#include "ui/widget.h"', + '#define PROTOBUF_NONNULL', + LEGACY_WIDGET, + 'namespace ui {', + 'void Paint() {', + ' auto* const w = new Widget();', + ' w->Draw();', + '}', + 'void PaintOwned(std::unique_ptr const w) {', + ' w->Draw();', + '}', + 'void PaintAll(std::vector& ws) {', + ' for (Widget* const w : ws) w->Draw();', + '}', + 'void PaintEach(std::vector>& ws) {', + ' for (std::unique_ptr const& w : ws) w->Draw();', + '}', + 'void PaintMarked(std::vector& ws) {', + ' for (Widget* PROTOBUF_NONNULL w : ws) w->Draw();', + '}', + '} // namespace ui', + ), + }); + try { + expect(calls(cg, 'ui::Paint')).toEqual(['ui::Widget::Draw @0.9']); + expect(calls(cg, 'ui::PaintOwned')).toEqual(['ui::Widget::Draw @0.9']); + expect(calls(cg, 'ui::PaintAll')).toEqual(['ui::Widget::Draw @0.9']); + expect(calls(cg, 'ui::PaintEach')).toEqual(['ui::Widget::Draw @0.9']); + expect(calls(cg, 'ui::PaintMarked')).toEqual(['ui::Widget::Draw @0.9']); + } finally { + cg.close(); + } + }); + + it('keeps an ALL-CAPS type name, which is no annotation before a `*`', async () => { + const cg = await indexed({ + 'handle.cc': lines( + 'struct OS_HANDLE {', + ' void Close() {}', + '};', + 'struct FILE_HANDLE {', + ' void Close() {}', + '};', + 'void Shut(OS_HANDLE* const h) {', + ' h->Close();', + '}', + ), + }); + try { + expect(calls(cg, 'Shut')).toEqual(['OS_HANDLE::Close @0.9']); + } finally { + cg.close(); + } + }); + + it('keeps the operator a qualified declaration takes: `.` on a reference, `->` on a pointer', async () => { + const cg = await indexed({ + 'ui/widget.h': UI_WIDGET, + 'ui/draw.cc': lines( + '#include "ui/widget.h"', + LEGACY_WIDGET, + 'namespace ui {', + 'void ByRef(Widget const& w) { w.Draw(); }', + 'void ByPtr(Widget const* const w) { w->Draw(); }', + '} // namespace ui', + ), + }); + try { + expect(calls(cg, 'ui::ByRef')).toEqual(['ui::Widget::Draw @0.9']); + expect(calls(cg, 'ui::ByPtr')).toEqual(['ui::Widget::Draw @0.9']); + } finally { + cg.close(); + } + }); + + it('reads globals the same-named header declares with qualifiers', async () => { + const cg = await indexed({ + 'registry.h': lines( + '#define PROTOBUF_NULLABLE', + 'class Registry {', + ' public:', + ' void Add() {}', + '};', + 'extern Registry* const g_registry;', + 'extern Registry* PROTOBUF_NULLABLE g_fallback;', + ), + 'registry.cc': lines( + '#include "registry.h"', + 'class Catalog {', + ' public:', + ' void Add() {}', + '};', + 'void Use() {', + ' g_registry->Add();', + '}', + 'void UseFallback() {', + ' g_fallback->Add();', + '}', + ), + }); + try { + expect(calls(cg, 'Use')).toEqual(['Registry::Add @0.9']); + expect(calls(cg, 'UseFallback')).toEqual(['Registry::Add @0.9']); + } finally { + cg.close(); + } + }); + + it('takes a parameter declared `std::function const Finish` for what a bare `Finish()` calls, not the method', async () => { + // C++ lookup no longer claims the call for the class's method; the name + // strategies decide, as for any parameter or local that shadows a member. + const cg = await indexed({ + 'job.cc': lines( + '#include ', + 'class Job {', + ' public:', + ' void Finish() {}', + ' void Run(std::function const Finish) {', + ' Finish();', + ' }', + '};', + ), + }); + try { + const resolvedBy = cg + .getCallees(callable(cg, 'Job::Run').id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => (r.edge.metadata as { resolvedBy?: string } | undefined)?.resolvedBy); + expect(resolvedBy).not.toContain('instance-method'); + } finally { + cg.close(); + } + }); + + it('reads a declaration without qualifiers as before', async () => { + const cg = await indexed({ + 'include/iterator.h': ITERATORS, + 'db/plain.cc': lines( + '#include "include/iterator.h"', + 'namespace rocksdb {', + 'void Plain(Iterator* iter) {', + ' iter->SeekToFirst();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Plain')).toEqual(['rocksdb::Iterator::SeekToFirst @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index a6bd02fb1d..4429cdc416 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8913,24 +8913,88 @@ function normalizeCppTypeName(typeName: string): string | null { return last; } +/** cv-qualifiers and `__restrict`, before or after a declarator's `*` or `&`: `Iterator* const iter`, `Slice const& key`. */ +const CPP_DECLARATOR_CV = String.raw`\b(?:const|volatile|__restrict__|__restrict)\b`; +/** + * What a pointer or reference declarator may carry after its `*` or `&`: + * clang's and abseil's nullability annotations (CPP_NULLABILITY), and the + * ALL-CAPS macros c-cpp.ts blanks for the parser — protobuf's `Arena* + * PROTOBUF_NULLABLE arena`, xxhash's `XXH_RESTRICT`. + */ +const CPP_DECLARATOR_ANNOTATION = String.raw`\b(?:absl_nonnull|absl_nullable|absl_nullability_unknown|_Nonnull|_Nullable|_Null_unspecified|[A-Z][A-Z0-9]*_[A-Z0-9_]*)\b`; +/** A declared type as the declarator regex reads it, what it carries included: `const FieldDescriptor* PROTOBUF_NULLABLE*`. */ +const CPP_DECLARATOR_TYPE = String.raw`[A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?(?:\s*(?:${CPP_DECLARATOR_CV}|[*&]+(?:\s*${CPP_DECLARATOR_ANNOTATION})*))*`; +/** An annotation macro's call after a declared name: leveldb's `VersionSet* const versions_ GUARDED_BY(mutex_);`. */ +const CPP_TRAILING_ANNOTATION = String.raw`\b[A-Z][A-Z0-9]*_[A-Z0-9_]*\s*\([^()]*\)`; + // Declarator regex: matches `Type receiver`, `Type* receiver`, `Type *receiver`, // `Type*receiver`, `Type 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. A range-based for's `Type receiver :` is read -// by cppRangeForDeclaration instead. +// by cppRangeForDeclaration instead. Qualifiers and annotations around the +// name are read past, and matchCppDeclarator drops the annotations from the +// type. function buildDeclaratorRegex(escapedReceiver: string): RegExp { return new RegExp( - `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`, + `(${CPP_DECLARATOR_TYPE})\\s*\\b${escapedReceiver}\\b(?:\\s*${CPP_TRAILING_ANNOTATION})*\\s*(?=[;=,)\\[{(]|$)`, ); } +/** buildDeclaratorRegex's `regex` run on the code `line`, with its type as cppDeclaratorType gives it. */ +function matchCppDeclarator(line: string, regex: RegExp): RegExpExecArray | null { + const m = regex.exec(line); + if (m?.[1] !== undefined) m[1] = cppDeclaratorType(m[1]); + return m; +} + +const CPP_DECLARATOR_ANNOTATION_WORD = new RegExp(`^${CPP_DECLARATOR_ANNOTATION}$`); +const CPP_WORD_AT = /[A-Za-z_]\w*/y; + +/** + * A type the declarator regex read, without the annotations it reads past + * after a `*` or `&`, outside template arguments: `Arena* + * PROTOBUF_NULLABLE` → `Arena*`. Taken as part of the type, one read as its + * name, of a class no project has. An ALL-CAPS word stands for an annotation + * only there, so a type's own name stays (`OS_HANDLE* const h`), and so do + * its cv-qualifiers, which what reads a type reads past. A type with nothing + * to drop comes back as it is. + */ +function cppDeclaratorType(text: string): string { + let out = ''; + let depth = 0; + let afterPointer = false; + let dropped = false; + for (let i = 0; i < text.length; ) { + CPP_WORD_AT.lastIndex = i; + const word = depth === 0 && !/[\w:]/.test(text[i - 1] ?? ' ') ? CPP_WORD_AT.exec(text)?.[0] : undefined; + if (word) { + if (afterPointer && CPP_DECLARATOR_ANNOTATION_WORD.test(word)) { + dropped = true; + } else { + out += word; + afterPointer = false; + } + i += word.length; + continue; + } + const c = text[i]!; + if (c === '<') depth++; + else if (c === '>') depth--; + else if (depth === 0 && (c === '*' || c === '&')) afterPointer = true; + else if (depth === 0 && !/\s/.test(c)) afterPointer = false; + out += c; + i++; + } + return dropped ? out.replace(/\s+/g, ' ').trim() : text; +} + // A range-based for's declaration, `for (const Foo& name :`, up to the single // colon (not `::`) that ends it. What may precede the type — `const`, a // global-scope `::` — is never an initializer, a call or a member access. const CPP_RANGE_FOR_DECLARATION = /\bfor\s*\(([^;=(){}?."']*?)\b([A-Za-z_]\w*)\s*:(?!:)/g; // The type a declaration ends with, read as the declarator regex reads it. -const CPP_DECLARED_TYPE_TAIL = /([A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?(?:\s*[*&]+)?)\s*$/; +const CPP_DECLARED_TYPE_TAIL = new RegExp(String.raw`(${CPP_DECLARATOR_TYPE})\s*$`); /** * The type the range-based for on `line` (comments blanked) declares @@ -8944,7 +9008,7 @@ function cppRangeForDeclaration(line: string, receiverName: string): { type: str for (const m of line.matchAll(CPP_RANGE_FOR_DECLARATION)) { if (m[2] !== receiverName) continue; const tail = m[1]!.match(CPP_DECLARED_TYPE_TAIL); - return tail ? { type: tail[1]!, paren: m.index! + m[0].indexOf('('), colon: m.index! + m[0].length } : null; + return tail ? { type: cppDeclaratorType(tail[1]!), paren: m.index! + m[0].indexOf('('), colon: m.index! + m[0].length } : null; } return null; } @@ -9168,7 +9232,7 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): for (let l = decl.startLine; l <= end; l++) { if (!word.test(lines[l - 1]!) || nested.some(([start, stop]) => l >= start && l <= stop)) continue; const code = cppCodeOf(cppLiteralsBlanked(lines[l - 1]!, l, rawLines)); - const m = code ? declarator.exec(code) : null; + const m = code ? matchCppDeclarator(code, declarator) : null; if (!m || code!.slice(m.index + m[0].length).startsWith('(')) continue; let depth = depths[l - decl.startLine]!; if (depth < 0) continue; @@ -10446,7 +10510,7 @@ function inferCppReceiverType( const line = cppCodeOf(cppLiteralsBlanked(text, i + 1, rawLines)); if (!line || !receiverPattern.test(line)) continue; - const declaratorMatch = line.match(declaratorRegex); + const declaratorMatch = matchCppDeclarator(line, declaratorRegex); if (declaratorMatch) { const normalized = normalizeCppTypeName(declaratorMatch[1] ?? ''); if (normalized === 'auto') { @@ -10530,7 +10594,7 @@ function inferCppReceiverType( if (!receiverPattern.test(text)) continue; const line = cppCodeOf(cppLiteralsBlanked(text, i + 1, headerRawLines)); if (!line) continue; - const declaratorMatch = line.match(declaratorRegex); + const declaratorMatch = matchCppDeclarator(line, declaratorRegex); if (!declaratorMatch) continue; const normalized = normalizeCppTypeName(declaratorMatch[1] ?? ''); if (normalized && normalized !== 'auto') { From bb817d453834e62c6d58842a23f72b9469d5240a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:41:00 +0000 Subject: [PATCH 02/29] fix(cpp): structured bindings and function wrappers give their types to calls on them (#2540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receiver inference had no type for a name a structured binding declares, for what a `std::function` returns, or for a class member whose type is written on the line above its name. rocksdb's MultiCfIteratorImpl writes `for (auto& [cfh, iter] : cfh_iter_pairs_) { iter->Valid(); … }` over a member declared across two lines as a `std::vector>>`; the calls were guessed by the method's name. - cppStructuredBindingType reads a binding's type at the name's place: an element of the range a range-for over the binding iterates, when the call is in its body, or of a declaration's initializer the calling function makes, a `std::pair` or `std::tuple` (cppBindingElements). A map's range elements are pairs of its key and value (CPP_LIBRARY_MAPS). - cppStatementFrom reads a statement on to its `;`, for a binding's initializer and an `auto` local's. - The declarator regex reads `std::function`, `std::move_only_function`, `std::copyable_function`, `absl::FunctionRef` and `absl::AnyInvocable` types, whose template argument is a function type, and cppExpressionType gives a call on such a variable what the function type returns (cppWrapperReturnType). - cppDataMemberIn reads a member whose type is the whole line above its name (CPP_TYPE_LINE), so a default member initializer continued on the next line (`bool ready_ = flag_ &&` above `it_;`) isn't taken for one. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-binding-and-wrapper-receivers.test.ts | 237 ++++++++++++++++++ src/resolution/name-matcher.ts | 147 ++++++++++- 3 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 __tests__/cpp-binding-and-wrapper-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aca5fb6e2..119127f14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call on an `auto` variable now links to the method of the class C++ finds for the type its initializer gives it, as a call on a variable declared with that type does. The type is read from a cast or smart pointer written out, like facebook/rocksdb's `auto iter = std::unique_ptr(…)` or `auto* mutex = static_cast(arg)`, from `new` or `std::make_unique` of a class, and from what a function or method is declared to return, like the `Iterator*` of `txn->GetIterator(…)`, read to the end of an initializer that goes on to the next line. Before, such a variable took the type of an earlier variable of the same name, or none: in RocksDB, `iter->SeekToFirst()` reached `MemTableRep::Iterator` and `mutex->Lock()` reached `port::Mutex`. A chained call like `cfd->current()->storage_info()` now looks up the class the inner call is declared to return the same way, through a smart pointer too, so protocolbuffers/protobuf's Java generator no longer reaches the C# generator's `EnumGenerator`. A type from outside the project no longer links to a class of the project that shares its name: protobuf's `status.ok()` on an `absl::Status` reached `upb::Status::ok`, and fmtlib/fmt's `os.flush()` on a `std::ostream&` reached fmt's own `ostream::flush`. Re-index C++ projects after upgrading. - In C++, a call on a data member of the calling method's class, like `versions_->GetColumnFamilySet()` in facebook/rocksdb's `db_impl_write.cc`, now links to the method of the member's type wherever the class is declared. Before, only the call's own file and the header of the same name were read for the member, and RocksDB declares `DBImpl` in `db_impl.h` while its methods are spread over `db_impl_write.cc`, `db_impl_compaction_flush.cc`, `db_impl_open.cc` and more, so such calls were guessed by the member's name. A member declared below an inline method that uses it, one a base class declares, and, in a gtest `TEST_F` or `TEST_P` body, one of the test's fixture, like RocksDB's `db_`, are found the same way, and a member of a class nested in the caller's class is no longer taken for the caller's own. A call on a member of a standard-library type, like `std::atomic shutting_down_`, no longer links to a project method that only shares the method's name. Re-index C++ projects after upgrading. - In C++, a variable, parameter or member whose declaration puts a qualifier or an annotation around its name is now read for its type, so calls on it link to the declared class's methods: a `const` after the `*` or `&`, like facebook/rocksdb's `Iterator* const iter = iter_guard.get();` or google/leveldb's `Env* const env_;`, a type written before its `const`, like `Slice const& key`, a nullability annotation after the `*`, like protocolbuffers/protobuf's `io::EpsCopyOutputStream* PROTOBUF_NONNULL stream`, and a thread-safety annotation after the name, like LevelDB's `MemTable* imm_ GUARDED_BY(mutex_);`. Before, such a declaration was skipped, or the annotation was taken for its type, so the call took the type of an earlier variable of the same name or was guessed by the method's name: RocksDB's `iter->SeekToFirst()` reached the unrelated `ArenaWrappedDBIter`, and LevelDB's `env_->NewWritableFile(…)` reached `InMemoryEnv`. Re-index C++ projects after upgrading. +- In C++, a call on a name a structured binding declares now links to the method of the type C++ gives it. That covers a range-based `for` over pairs or a map, like facebook/rocksdb's `for (auto& [cfh, iter] : cfh_iter_pairs_) { iter->Valid(); }`, and a declaration like protocolbuffers/protobuf's `auto [dst_ext, is_new] = Insert(…);`. A call on what a `std::function` or another function wrapper returns, like `create_iterator()->Valid()` on a `std::function`, links the same way, and so does a call on a class member whose type is written on the line above its name. Before, these calls were guessed by the method's name, and calls on a library container declared that way, like a `std::priority_queue` member's `pop()`, reached a project class's method of that name. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-binding-and-wrapper-receivers.test.ts b/__tests__/cpp-binding-and-wrapper-receivers.test.ts new file mode 100644 index 0000000000..eae9019559 --- /dev/null +++ b/__tests__/cpp-binding-and-wrapper-receivers.test.ts @@ -0,0 +1,237 @@ +/** + * A C++ call on a name a structured binding declares, or on what a + * `std::function` returns, links to the method of the type C++ gives it. + * + * facebook/rocksdb's MultiCfIteratorImpl writes `for (auto& [cfh, iter] : + * cfh_iter_pairs_) { iter->Valid(); … }` over a member declared, across two + * lines, as a `std::vector>>`; a binding's names had no type, so the calls + * were guessed by the method's name. `iter` is the pair's second element, a + * `std::unique_ptr`, and `->` reaches `Iterator`'s method. A map's + * elements pair its key and value; a declaration's (`auto [a, b] = f();`) + * are what its initializer returns, a `std::pair` or `std::tuple`. And a + * call through a `std::function`, or the other wrappers, has + * the type the function type returns. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-binding-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` edges of a function or method, as `qualifiedName @confidence`, one per call. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getOutgoingEdgesFrom([callable(cg, caller).id], ['calls']) + .map((e) => `${cg.getNode(e.target)?.qualifiedName} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +// `Valid` is also a namesake class's, so a guess by the name can miss. +const ITERATORS = lines( + '#include ', + 'namespace rocksdb {', + 'class ColumnFamilyHandle {};', + 'class IteratorBase {', + ' public:', + ' virtual bool Valid() const { return false; }', + '};', + 'class Iterator : public IteratorBase {};', + 'class MemTableIterator {', + ' public:', + ' bool Valid() const { return true; }', + '};', + '} // namespace rocksdb', +); + +describe('C++ structured bindings and function wrappers as receivers', () => { + it("is the pair element a range-for's binding names, over a member declared across two lines", async () => { + const cg = await indexed({ + 'include/rocksdb/iterator.h': ITERATORS, + 'db/multi_cf_iterator_impl.h': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'class MultiCfIteratorImpl {', + ' public:', + ' void SeekToFirst() {', + ' for (auto& [cfh, iter] : cfh_iter_pairs_) {', + ' iter->Valid();', + ' }', + ' }', + ' private:', + ' std::vector>>', + ' cfh_iter_pairs_;', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::MultiCfIteratorImpl::SeekToFirst')).toEqual(['rocksdb::IteratorBase::Valid @0.9']); + } finally { + cg.close(); + } + }); + + it("is a map's value, and a declaration's pair or tuple element", async () => { + const cg = await indexed({ + 'include/rocksdb/iterator.h': ITERATORS, + 'db/use.cc': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'std::pair MakePair() { return {nullptr, nullptr}; }', + 'std::tuple MakeTuple() { return {0, nullptr, false}; }', + 'void ByName(const absl::flat_hash_map& iters) {', + ' for (const auto& [name, iter] : iters) {', + ' iter->Valid();', + ' }', + '}', + 'void ByPair() {', + ' auto [cfh, iter] = MakePair();', + ' iter->Valid();', + '}', + 'void ByTuple() {', + ' const auto& [count, iter, done] = MakeTuple();', + ' iter->Valid();', + '}', + '} // namespace rocksdb', + ), + }); + try { + for (const caller of ['ByName', 'ByPair', 'ByTuple']) { + expect(calls(cg, `rocksdb::${caller}`).filter((c) => c.includes('Valid'))).toEqual(['rocksdb::IteratorBase::Valid @0.9']); + } + } finally { + cg.close(); + } + }); + + it("is what a std::function's function type returns, called or as an auto local's initializer", async () => { + const cg = await indexed({ + 'include/rocksdb/iterator.h': ITERATORS, + 'db/scan.cc': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'void Scan(std::function create_iterator) {', + ' create_iterator()->Valid();', + ' auto* it = create_iterator();', + ' it->Valid();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Scan')).toEqual(['rocksdb::IteratorBase::Valid @0.85', 'rocksdb::IteratorBase::Valid @0.9']); + } finally { + cg.close(); + } + }); + + it("gives no type from another function's binding, or a template's arguments that aren't a pair's", async () => { + const cg = await indexed({ + 'include/rocksdb/iterator.h': ITERATORS, + 'db/use.cc': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'MemTableIterator* iter = nullptr;', + 'std::pair MakePair() { return {nullptr, nullptr}; }', + '// Members in another order than the template parameters.', + 'template struct Duo { B second; A first; };', + 'Duo MakeDuo() { return {}; }', + 'void Earlier() {', + ' auto [cfh, iter] = MakePair();', + '}', + 'void Later() {', + ' iter->Valid();', + '}', + 'void Unpack() {', + ' auto [p, q] = MakeDuo();', + ' p->Valid();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Later').filter((c) => c === 'rocksdb::IteratorBase::Valid @0.9')).toEqual([]); + expect(calls(cg, 'rocksdb::Unpack').filter((c) => c === 'rocksdb::IteratorBase::Valid @0.9')).toEqual([]); + } finally { + cg.close(); + } + }); + + it("reads a member's type from the line above only when that line is a type", async () => { + const cg = await indexed({ + 'include/rocksdb/iterator.h': ITERATORS, + 'db/reader.h': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'class Reader {', + ' public:', + ' bool Check() const { return it_->Valid(); }', + ' private:', + ' bool flag_ = true;', + ' // A default member initializer continued on the next line.', + ' bool ready_ = flag_ &&', + ' it_;', + ' MemTableIterator* it_ = nullptr;', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Reader::Check')).toEqual(['rocksdb::MemTableIterator::Valid @0.9']); + } finally { + cg.close(); + } + }); + + it("gives no type for a call outside the loop, or a binding of something that isn't a pair or tuple", async () => { + const cg = await indexed({ + 'include/rocksdb/iterator.h': ITERATORS, + 'db/use.cc': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'struct Point { Iterator* first; Iterator* second; };', + 'void After(const std::vector>& pairs, MemTableIterator* iter) {', + ' for (const auto& [cfh, iter] : pairs) {', + ' }', + ' iter->Valid();', + '}', + 'void Aggregate(Point point) {', + ' auto [x, y] = point;', + ' y->Valid();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::After').filter((c) => c === 'rocksdb::IteratorBase::Valid @0.9')).toEqual([]); + expect(calls(cg, 'rocksdb::Aggregate').filter((c) => c === 'rocksdb::IteratorBase::Valid @0.9')).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 4429cdc416..669d2f158f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8922,8 +8922,15 @@ const CPP_DECLARATOR_CV = String.raw`\b(?:const|volatile|__restrict__|__restrict * PROTOBUF_NULLABLE arena`, xxhash's `XXH_RESTRICT`. */ const CPP_DECLARATOR_ANNOTATION = String.raw`\b(?:absl_nonnull|absl_nullable|absl_nullability_unknown|_Nonnull|_Nullable|_Null_unspecified|[A-Z][A-Z0-9]*_[A-Z0-9_]*)\b`; +/** + * A function wrapper's type, whose template argument is a function type: + * rocksdb's `std::function create_iterator`. + */ +const CPP_FUNCTION_WRAPPER_TYPE = String.raw`(?:::\s*)?(?:std\s*::\s*(?:function|move_only_function|copyable_function)|absl\s*::\s*(?:FunctionRef|AnyInvocable))\s*<[^;={}]*>`; /** A declared type as the declarator regex reads it, what it carries included: `const FieldDescriptor* PROTOBUF_NULLABLE*`. */ -const CPP_DECLARATOR_TYPE = String.raw`[A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?(?:\s*(?:${CPP_DECLARATOR_CV}|[*&]+(?:\s*${CPP_DECLARATOR_ANNOTATION})*))*`; +const CPP_DECLARATOR_TYPE = String.raw`(?:${CPP_FUNCTION_WRAPPER_TYPE}|[A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?)(?:\s*(?:${CPP_DECLARATOR_CV}|[*&]+(?:\s*${CPP_DECLARATOR_ANNOTATION})*))*`; +/** A line that is a declared type and nothing else, what may come before a type included: `const std::map&`. */ +const CPP_TYPE_LINE = new RegExp(String.raw`^\s*(?:(?:const|volatile|static|mutable|constexpr|inline)\s+)*${CPP_DECLARATOR_TYPE}\s*$`); /** An annotation macro's call after a declared name: leveldb's `VersionSet* const versions_ GUARDED_BY(mutex_);`. */ const CPP_TRAILING_ANNOTATION = String.raw`\b[A-Z][A-Z0-9]*_[A-Z0-9_]*\s*\([^()]*\)`; @@ -9062,6 +9069,10 @@ const CPP_LIBRARY_SEQUENCES: ReadonlySet = new Set([ ]); /** Widely used project containers of the same kind: protobuf's repeated fields, rocksdb's `autovector`. */ const CPP_PROJECT_SEQUENCES: ReadonlySet = new Set(['RepeatedField', 'RepeatedPtrField', 'autovector']); +/** The standard library's and abseil's maps, whose elements are `std::pair`s of a key and a value. */ +const CPP_LIBRARY_MAPS: ReadonlySet = new Set([ + 'map', 'multimap', 'unordered_map', 'unordered_multimap', 'flat_hash_map', 'node_hash_map', 'btree_map', 'btree_multimap', +]); /** Clang's and abseil's nullability annotations: `const FieldDescriptor* absl_nonnull`. */ const CPP_NULLABILITY = /\b(?:absl_nonnull|absl_nullable|absl_nullability_unknown|_Nonnull|_Nullable|_Null_unspecified)\b/g; @@ -9231,18 +9242,32 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): const depths = cppClassParenDepths(decl, lines, end, rawLines, context); for (let l = decl.startLine; l <= end; l++) { if (!word.test(lines[l - 1]!) || nested.some(([start, stop]) => l >= start && l <= stop)) continue; - const code = cppCodeOf(cppLiteralsBlanked(lines[l - 1]!, l, rawLines)); - const m = code ? matchCppDeclarator(code, declarator) : null; + let code = cppCodeOf(cppLiteralsBlanked(lines[l - 1]!, l, rawLines)); + // The line the declaration's code starts on: the one above, for a type + // written there and nothing else (rocksdb's + // `std::vector>>` + // above `cfh_iter_pairs_;`), so a default member initializer continued + // on the next line (`bool ready_ = flag_ &&` above `it_;`) isn't one. + let from = l; + let m = code ? matchCppDeclarator(code, declarator) : null; + if (!m && code && l > decl.startLine) { + const above = cppCodeOf(cppLiteralsBlanked(lines[l - 2]!, l - 1, rawLines)); + if (above && CPP_TYPE_LINE.test(above)) { + code = `${above} ${code}`; + from = l - 1; + m = matchCppDeclarator(code, declarator); + } + } if (!m || code!.slice(m.index + m[0].length).startsWith('(')) continue; - let depth = depths[l - decl.startLine]!; + let depth = depths[from - decl.startLine]!; if (depth < 0) continue; - for (const c of code!.slice(l === decl.startLine ? decl.startColumn : 0, m.index)) { + for (const c of code!.slice(from === decl.startLine ? decl.startColumn : 0, m.index)) { if (c === '(') depth++; else if (c === ')') depth--; } const type = depth <= 0 ? normalizeCppTypeName(m[1]!) : null; if (type && type !== 'auto') { - member = { text: m[1]!, scope: decl.qualifiedName, file: decl.filePath, line: l, parameters: cppTemplateParameters(decl, context) }; + member = { text: m[1]!, scope: decl.qualifiedName, file: decl.filePath, line: from, parameters: cppTemplateParameters(decl, context) }; break; } } @@ -9412,6 +9437,11 @@ function cppRangeElementOf(type: CppWrittenType, at: UnresolvedRef, context: Res const element = cppTemplateArguments(range.text)?.[0]; return element ? { ...range, text: cppWithoutNullability(element) } : null; } + // A map's elements pair its key with its value. + if (CPP_LIBRARY_MAPS.has(last) && (segments[0] === 'std' || segments[0] === 'absl')) { + const [key, value] = cppTemplateArguments(range.text) ?? []; + return key && value ? { ...range, text: `std::pair` } : null; + } const cls = cppWrittenClass(range, at, context); const begin = cls ? cppReturnTypeOf(cppMethodsOf(cls, 'begin', at, context), context) : null; return begin ? cppIteratorElement(begin, at, context, hops) : null; @@ -9500,6 +9530,12 @@ function cppExpressionType(expr: string, at: UnresolvedRef, context: ResolutionC if (made) return { ...callers, text: `std::${made[1]}_ptr<${made[2]!.trim()}>` }; // `std::unique_ptr(…)`: the smart pointer or optional it makes. if (cppStdHeldType(callee)) return { ...callers, text: callee }; + // `create_iterator()` on a `std::function create_iterator`: what its function type returns. + if (/^[A-Za-z_]\w*$/.test(callee)) { + const wrapper = cppExpressionType(callee, at, context, depth + 1); + const returned = wrapper && cppWrapperReturnType(wrapper); + if (returned) return returned; + } // `recv.method(…)`, `recv->method(…)`: the method of the receiver's class. const member = /^(.+?)\s*(\.|->)\s*([A-Za-z_]\w*)$/.exec(callee); if (member) { @@ -10566,6 +10602,14 @@ function inferCppReceiverType( return cppDeclaredType(element.text, elementType, true, ref, context, found); } } + // `for (auto& [cfh, iter] : cfh_iter_pairs_)`, `auto [status, iter] = Open();`. + const bound = !found.shadowed ? cppStructuredBindingType(line, lines, i, receiverName, callLineIndex, ref, context, rawLines) : null; + const boundType = bound && normalizeCppTypeName(bound.text); + if (bound && boundType && boundType !== 'auto') { + noteCppDeclaration(found, bound.text, true); + found.declared = bound; + return cppDeclaredType(bound.text, boundType, true, ref, context, found); + } if (cppRebindsReceiver(line, escapedReceiver)) found.shadowed = true; } } @@ -10761,8 +10805,19 @@ function cppAutoInitializerStatement( ): string | null { const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const start = new RegExp(`\\b${escaped}\\b\\s*=(?!=)`).exec(line); - if (!start) return null; - let text = line.slice(start.index + start[0].length); + return start ? cppStatementFrom(line.slice(start.index + start[0].length), lines, index, rawLines) : null; +} + +/** + * The C++ statement that `text`, the rest of `lines[index]`, begins, read on + * to the `;` that ends it. Null when no `;` ends it within a few lines. + */ +function cppStatementFrom( + text: string, + lines: readonly string[], + index: number, + rawLines: ReturnType, +): string | null { for (let next = index + 1; ; next++) { const end = cppStatementEnd(text); if (end >= 0) return text.slice(0, end).trim() || null; @@ -10771,6 +10826,82 @@ function cppAutoInitializerStatement( } } +/** A structured binding's names: `[cfh, iter]` in `for (auto& [cfh, iter] : …)` or `const auto [status, iter] = …;`. */ +const CPP_STRUCTURED_BINDING = /\bauto\s*&{0,2}\s*\[([^\]]*)\]/g; + +/** + * The type the structured binding on line `index` (`line`, its code) gives + * `receiverName`, as written: the element at the name's place of the pair + * or tuple bound. That is an element of the range a range-for over the + * binding iterates, when the call is in the loop's body — rocksdb's `for + * (auto& [cfh, iter] : cfh_iter_pairs_)` over a + * `std::vector>>`, + * or a map's key and value — or the initializer of a declaration the + * calling function makes (`auto [status, iter] = Open();`). Null when that + * can't be read. + */ +function cppStructuredBindingType( + line: string, + lines: readonly string[], + index: number, + receiverName: string, + callLineIndex: number, + ref: UnresolvedRef, + context: ResolutionContext, + rawLines: ReturnType, +): CppWrittenType | null { + if (!line.includes('[')) return null; + for (const m of line.matchAll(CPP_STRUCTURED_BINDING)) { + const place = m[1]!.split(',').map((name) => name.trim()).indexOf(receiverName); + if (place < 0) continue; + const end = m.index! + m[0].length; + const loop = /\bfor\s*\(\s*(?:(?:const|volatile)\s+)*$/.exec(line.slice(0, m.index!)); + const colon = /^\s*:(?!:)/.exec(line.slice(end)); + let value: CppWrittenType | null = null; + if (loop && colon) { + if (!cppForBodyEncloses(lines, index, line.indexOf('(', loop.index), callLineIndex, ref.column)) return null; + value = cppAutoLoopElement(lines, index, { colon: end + colon[0].length }, ref, context); + } else { + const assign = /^\s*=(?!=)/.exec(line.slice(end)); + if (!assign || !isCppCallersDeclaration(ref.filePath, index + 1, ref, context)) return null; + const init = cppStatementFrom(line.slice(end + assign[0].length), lines, index, rawLines); + value = init ? cppAutoLocalType(init, index + 1, m[0], ref, context) : null; + } + return (value && cppBindingElements(value, ref, context)?.[place]) ?? null; + } + return null; +} + +/** + * What calling a function wrapper of the written type `type` returns, as + * written: what the function type that is its template argument returns + * (`Iterator*` for `std::function`). Only a wrapper's + * declaration is read with one (CPP_FUNCTION_WRAPPER_TYPE). Null when its + * template argument is no function type. + */ +function cppWrapperReturnType(type: CppWrittenType): CppWrittenType | null { + const signature = cppTemplateArguments(type.text)?.[0] ?? ''; + let depth = 0; + for (let i = 0; i < signature.length; i++) { + const c = signature[i]!; + if (c === '<') depth++; + else if (c === '>') depth--; + else if (c === '(' && depth === 0) { + const returned = signature.slice(0, i).trim(); + return returned ? { ...type, text: returned } : null; + } + } + return null; +} + +/** What a structured binding of a value of the type `type` names, in order: a `std::pair`'s or `std::tuple`'s elements. */ +function cppBindingElements(type: CppWrittenType, at: UnresolvedRef, context: ResolutionContext): CppWrittenType[] | null { + const followed = cppFollowWrittenAliases(type, at, context); + const segments = followed && cppTypeSegments(followed.text); + if (!followed || segments?.length !== 2 || segments[0] !== 'std' || (segments[1] !== 'pair' && segments[1] !== 'tuple')) return null; + return cppTemplateArguments(followed.text)?.map((text) => ({ ...followed, text: cppWithoutNullability(text) })) ?? null; +} + /** Where the statement that `text` begins ends: its first `;` outside brackets, or -1. */ function cppStatementEnd(text: string): number { let depth = 0; From 8d918067f80b02e8c0cfc3a9433ec0274bd8dccc Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:42:15 +0000 Subject: [PATCH 03/29] fix(cpp): a bare call in a gtest TEST_F or TEST_P body reaches the fixture's method (#2537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index holds a gtest `TEST_F(Fixture, Name)` / `TEST_P` body as a function named `TEST_F`; gtest makes it a method of a class derived from `Fixture`. #2533 gave such a body the fixture's data members (cppTestFixture), not its methods. rocksdb's tests call `DBTestBase`'s `Put(…)`, `Flush()`, `Key(i)` and `NewIterator(…)` bare, and those calls were guessed by name, often reaching a namesake in another class at 0.4 (`ColumnFamilyTestBase::Put`); `auto iter = NewIterator(…)` gave `iter` no type, so `iter->SeekToFirst()` reached the nested `MemTableRep::Iterator`. - cppImplicitThisMethod is #2479's lookup, taken out of matchCppImplicitThisCall: the caller's class, its bases, the classes around it, with cppTestFixture as the owner of a gtest body. matchCppImplicitThisCall keeps the local-shadowing check for the link. - cppExpressionType types a bare call by the same lookup, as on `this`, instead of the class's first declaration. Of two classes sharing a name in different test files it reads the caller's own, and in a template a dependent base a using-declaration names still counts, as before (protobuf's `using RepeatedFieldWrapper::MutableRepeatedField;`). `this` in a gtest body points to the fixture. - A `->` call on a value of a type from outside the project no longer takes the type's last name for the class it is in (isCppArrowThroughOutsideType); the name strategies decide. matchCppDeclaredTypeCall steps aside for `->` on a value before #2532's outside-type check, so typing JsonTest's `auto m = ToProto(…)` (an `absl::StatusOr`) had sent `m->value()` to protobuf's own `hpb::StatusOr::value` at 0.9. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-test-fixture-call.test.ts | 407 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 68 +++- 3 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 __tests__/cpp-test-fixture-call.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 119127f14f..9641befea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call on a data member of the calling method's class, like `versions_->GetColumnFamilySet()` in facebook/rocksdb's `db_impl_write.cc`, now links to the method of the member's type wherever the class is declared. Before, only the call's own file and the header of the same name were read for the member, and RocksDB declares `DBImpl` in `db_impl.h` while its methods are spread over `db_impl_write.cc`, `db_impl_compaction_flush.cc`, `db_impl_open.cc` and more, so such calls were guessed by the member's name. A member declared below an inline method that uses it, one a base class declares, and, in a gtest `TEST_F` or `TEST_P` body, one of the test's fixture, like RocksDB's `db_`, are found the same way, and a member of a class nested in the caller's class is no longer taken for the caller's own. A call on a member of a standard-library type, like `std::atomic shutting_down_`, no longer links to a project method that only shares the method's name. Re-index C++ projects after upgrading. - In C++, a variable, parameter or member whose declaration puts a qualifier or an annotation around its name is now read for its type, so calls on it link to the declared class's methods: a `const` after the `*` or `&`, like facebook/rocksdb's `Iterator* const iter = iter_guard.get();` or google/leveldb's `Env* const env_;`, a type written before its `const`, like `Slice const& key`, a nullability annotation after the `*`, like protocolbuffers/protobuf's `io::EpsCopyOutputStream* PROTOBUF_NONNULL stream`, and a thread-safety annotation after the name, like LevelDB's `MemTable* imm_ GUARDED_BY(mutex_);`. Before, such a declaration was skipped, or the annotation was taken for its type, so the call took the type of an earlier variable of the same name or was guessed by the method's name: RocksDB's `iter->SeekToFirst()` reached the unrelated `ArenaWrappedDBIter`, and LevelDB's `env_->NewWritableFile(…)` reached `InMemoryEnv`. Re-index C++ projects after upgrading. - In C++, a call on a name a structured binding declares now links to the method of the type C++ gives it. That covers a range-based `for` over pairs or a map, like facebook/rocksdb's `for (auto& [cfh, iter] : cfh_iter_pairs_) { iter->Valid(); }`, and a declaration like protocolbuffers/protobuf's `auto [dst_ext, is_new] = Insert(…);`. A call on what a `std::function` or another function wrapper returns, like `create_iterator()->Valid()` on a `std::function`, links the same way, and so does a call on a class member whose type is written on the line above its name. Before, these calls were guessed by the method's name, and calls on a library container declared that way, like a `std::priority_queue` member's `pop()`, reached a project class's method of that name. Re-index C++ projects after upgrading. +- In C++, a call written without a receiver in a gtest `TEST_F` or `TEST_P` body now links to the test fixture's method, its own or one it inherits, as C++ finds it: facebook/rocksdb's tests call `DBTestBase`'s `Put(…)`, `Flush()` and `NewIterator(…)` this way. A variable such a call initializes, like `auto iter = NewIterator(…)`, takes the type the method returns, so `iter->SeekToFirst()` reaches the iterator class. Before, those calls were guessed by name and often reached a namesake in another class, like RocksDB's `ColumnFamilyTestBase::Put` or protocolbuffers/protobuf's `EncodeDecodeTest::ExpectNoErrors`, and `iter->SeekToFirst()` reached the unrelated `MemTableRep::Iterator`. A `->` call on a value of a type from outside the project, like protobuf's `m->value()` on an `absl::StatusOr`, no longer links to a class of the project that shares the type's name, like protobuf's own `hpb::StatusOr`. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-test-fixture-call.test.ts b/__tests__/cpp-test-fixture-call.test.ts new file mode 100644 index 0000000000..1406adcc57 --- /dev/null +++ b/__tests__/cpp-test-fixture-call.test.ts @@ -0,0 +1,407 @@ +/** + * A bare call in a gtest `TEST_F(Fixture, Name)` or `TEST_P` body reaches + * the fixture's method. + * + * The index holds such a body as a function named `TEST_F`, but gtest makes + * it a method of a class it derives from `Fixture`, declared where the + * `TEST_F` is. C++ looks a name the body calls up in that class, then in the + * fixture and the classes it derives from, before the namespace. Since PR + * #2533 the fixture's data members were found that way (`db_->Put(…)`); its + * methods were not, so in facebook/rocksdb `Put(…)`, `Flush()` and `auto iter + * = NewIterator(…)` — methods of `DBTestBase` — were guessed by name, and + * `iter->SeekToFirst()` reached the nested `MemTableRep::Iterator`. + * + * A guess can land on the right method by a shared word, so these tests + * check the confidence, not only the target. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-fixture-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +/** The function or method `qualifiedName`, the one in `file` when given. */ +function callable(cg: CodeGraph, qualifiedName: string, file?: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find( + (n) => n.qualifiedName === qualifiedName && (file === undefined || n.filePath === file), + ); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName @confidence`, one per call site. */ +function calls(cg: CodeGraph, caller: string, file?: string): string[] { + return cg + .getCallees(callable(cg, caller, file).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +/** `calls` callees as `qualifiedName by resolvedBy`. */ +function resolvers(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} by ${(r.edge.metadata as { resolvedBy?: string } | undefined)?.resolvedBy}`) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** rocksdb's public iterator, the nested namesake, and the test fixture base, trimmed. */ +const ROCKSDB = { + 'include/rocksdb/iterator.h': lines( + 'namespace rocksdb {', + 'class IteratorBase {', + ' public:', + ' virtual void SeekToFirst() = 0;', + ' virtual bool Valid() const = 0;', + '};', + 'class Iterator : public IteratorBase {};', + '} // namespace rocksdb', + ), + 'db/db_test_util.h': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'class DBTestBase {', + ' public:', + ' Iterator* NewIterator();', + ' void Put(int key);', + ' void Flush();', + '};', + '} // namespace rocksdb', + ), + // As in rocksdb, the fixture base's methods are defined out of line. + 'db/db_test_util.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + 'Iterator* DBTestBase::NewIterator() { return nullptr; }', + 'void DBTestBase::Put(int key) {}', + 'void DBTestBase::Flush() {}', + '} // namespace rocksdb', + ), +}; + +/** Namesakes beside the tests, which a guess by name prefers. */ +const NAMESAKES = lines( + 'class MemTableRep {', + ' public:', + ' class Iterator {', + ' public:', + ' void SeekToFirst() {}', + ' bool Valid() const { return false; }', + ' };', + ' Iterator* NewIterator() { return nullptr; }', + '};', + 'class WriteBatch {', + ' public:', + ' void Put(int key) {}', + ' void Flush() {}', + '};', +); + +describe('C++ bare calls in gtest TEST_F / TEST_P bodies', () => { + it("reaches the fixture's own and inherited methods", async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_basic_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + NAMESAKES, + 'class DBBasicTest : public DBTestBase {', + ' public:', + ' void Reopen() {}', + '};', + 'TEST_F(DBBasicTest, PutFlush) {', + ' Put(1);', + ' Flush();', + ' Reopen();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::TEST_F')).toEqual([ + 'rocksdb::DBBasicTest::Reopen @0.9', + 'rocksdb::DBTestBase::Flush @0.9', + 'rocksdb::DBTestBase::Put @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('types `auto iter = NewIterator()` from the fixture\'s method', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_iterator_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + NAMESAKES, + 'class DBIteratorTest : public DBTestBase {};', + 'TEST_F(DBIteratorTest, IterSeekToFirst) {', + ' auto iter = NewIterator();', + ' iter->SeekToFirst();', + ' if (iter->Valid()) {}', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::TEST_F')).toEqual([ + 'rocksdb::DBTestBase::NewIterator @0.9', + 'rocksdb::IteratorBase::SeekToFirst @0.9', + 'rocksdb::IteratorBase::Valid @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('does the same in a TEST_P body, and on `this->`', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_param_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + NAMESAKES, + 'class DBParamTest : public DBTestBase {};', + 'TEST_P(DBParamTest, Write) {', + ' this->Put(2);', + ' Flush();', + ' auto it = this->NewIterator();', + ' it->SeekToFirst();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::TEST_P')).toEqual([ + 'rocksdb::DBTestBase::Flush @0.9', + 'rocksdb::DBTestBase::NewIterator @0.9', + 'rocksdb::DBTestBase::Put @0.9', + 'rocksdb::IteratorBase::SeekToFirst @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('uses the fixture declared in the test\'s own file, of two sharing its name', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/other_test_util.h': lines( + 'namespace rocksdb {', + NAMESAKES, + 'class OtherTestBase {', + ' public:', + ' MemTableRep::Iterator* NewIterator() { return nullptr; }', + '};', + '} // namespace rocksdb', + ), + 'db/a_test.cc': lines( + '#include "db/other_test_util.h"', + 'namespace rocksdb {', + 'class SharedTest : public OtherTestBase {};', + '} // namespace rocksdb', + ), + 'db/b_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + 'class SharedTest : public DBTestBase {};', + 'TEST_F(SharedTest, Seek) {', + ' auto iter = NewIterator();', + ' iter->SeekToFirst();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::TEST_F', 'db/b_test.cc')).toEqual([ + 'rocksdb::DBTestBase::NewIterator @0.9', + 'rocksdb::IteratorBase::SeekToFirst @0.9', + ]); + } finally { + cg.close(); + } + }); + + it("types a method's bare call on what the inherited method returns, past a nested class's parameter of its name", async () => { + // leveldb's SpecialEnv: a class declared in the method takes a `target` + // parameter, which is no local the bare `target()` calls. + const cg = await indexed({ + 'db/db_test.cc': lines( + 'namespace leveldb {', + 'class Env {', + ' public:', + ' virtual int NewRandomAccessFile() { return 0; }', + '};', + 'class EnvWrapper : public Env {', + ' public:', + ' Env* target() const { return target_; }', + ' private:', + ' Env* target_;', + '};', + 'class SpecialEnv : public EnvWrapper {', + ' public:', + ' int NewRandomAccessFile() override {', + ' class CountingFile {', + ' public:', + ' explicit CountingFile(Env* target) : target_(target) {}', + ' private:', + ' Env* target_;', + ' };', + ' return target()->NewRandomAccessFile();', + ' }', + '};', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::SpecialEnv::NewRandomAccessFile')).toContain('leveldb::Env::NewRandomAccessFile @0.85'); + } finally { + cg.close(); + } + }); + + it("types a template's bare call into a base it names with a using-declaration", async () => { + // protobuf's RepeatedFieldPrimitiveAccessor: `MutableRepeatedField` is + // its dependent base's, brought in by `using RepeatedFieldWrapper::MutableRepeatedField;`. + const cg = await indexed({ + 'src/google/protobuf/reflection_internal.h': lines( + 'namespace google {', + 'namespace protobuf {', + 'template ', + 'class RepeatedField {', + ' public:', + ' void Swap(RepeatedField* other) {}', + '};', + 'template ', + 'class RepeatedFieldWrapper {', + ' public:', + ' RepeatedField* MutableRepeatedField(void* data) const { return nullptr; }', + '};', + 'template ', + 'class RepeatedFieldPrimitiveAccessor final : public RepeatedFieldWrapper {', + ' using RepeatedFieldWrapper::MutableRepeatedField;', + ' public:', + ' void Swap(void* data, void* other_data) const {', + ' MutableRepeatedField(data)->Swap(MutableRepeatedField(other_data));', + ' }', + '};', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::RepeatedFieldPrimitiveAccessor::Swap')).toContain('google::protobuf::RepeatedField::Swap @0.85'); + } finally { + cg.close(); + } + }); + + it("gives `->` on a value of a type from outside the project no project class sharing the type's name", async () => { + // protobuf's JsonTest: `ToProto(…)` returns an `absl::StatusOr`, + // whose `->` reaches the `Any` it holds, not protobuf's own `hpb::StatusOr`. + const cg = await indexed({ + 'hpb/status.h': lines( + 'namespace hpb {', + 'template ', + 'class StatusOr {', + ' public:', + ' T& value() { return value_; }', + ' private:', + ' T value_;', + '};', + '} // namespace hpb', + ), + 'src/google/protobuf/json/json_test.cc': lines( + '#include "hpb/status.h"', + 'namespace google {', + 'namespace protobuf {', + 'namespace json {', + 'class JsonTest {', + ' protected:', + ' template ', + ' absl::StatusOr ToProto(absl::string_view json) { return Proto(); }', + '};', + 'TEST_P(JsonTest, Any) {', + ' auto m = ToProto("{}");', + ' m->value();', + '}', + 'void Parsed(absl::StatusOr parsed) {', + ' parsed->value();', + '}', + '} // namespace json', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + // (The name strategies still decide, as for any receiver of an unknown type; + // in this small project the one `value` is that one.) + expect(calls(cg, 'google::protobuf::json::TEST_P')).not.toContain('hpb::StatusOr::value @0.9'); + expect(calls(cg, 'google::protobuf::json::Parsed')).not.toContain('hpb::StatusOr::value @0.9'); + } finally { + cg.close(); + } + }); + + it('leaves a TEST body, which has no fixture, to the name strategies', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_plain_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + 'class DBPlainTest : public DBTestBase {};', + 'TEST(DBPlainTest, Plain) {', + ' Put(1);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(resolvers(cg, 'rocksdb::TEST')).not.toContain('rocksdb::DBTestBase::Put by instance-method'); + } finally { + cg.close(); + } + }); + + it('calls a local of the name the body declares, not the fixture\'s method', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_local_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + 'class DBLocalTest : public DBTestBase {};', + 'TEST_F(DBLocalTest, Local) {', + ' auto Put = [](int key) {};', + ' Put(1);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(resolvers(cg, 'rocksdb::TEST_F')).not.toContain('rocksdb::DBTestBase::Put by instance-method'); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 669d2f158f..fa0f6498e9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9496,8 +9496,11 @@ function cppExpressionType(expr: string, at: UnresolvedRef, context: ResolutionC if (!caller) return null; // What the calling function writes is looked up from inside it. const callers = { scope: caller.qualifiedName, file: at.filePath, line: at.line, parameters: cppTemplateParameters(caller, context) }; - const owner = cppMemberOwner(caller, context); - if (text === 'this') return owner ? { ...callers, text: `${owner}*` } : null; + if (text === 'this') { + // (A gtest `TEST_F` body's object is of a class derived from its fixture.) + const owner = cppMemberOwner(caller, context) ?? cppTestFixture(caller, at, context); + return owner ? { ...callers, text: `${owner}*` } : null; + } const name = /^(?:this\s*->\s*)?([A-Za-z_]\w*)$/.exec(text); if (name) { const decl: CppReceiverDeclaration = {}; @@ -9545,10 +9548,14 @@ function cppExpressionType(expr: string, at: UnresolvedRef, context: ResolutionC } if (!/^(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*(?:\s*<[\s\S]*>)?$/.test(callee)) return null; const spelled = stripCppTemplateArguments(callee).replace(/\s+/g, ''); - // `NewIterator(…)` in a method: its class's own or inherited method first. - const ownClass = owner && !spelled.includes('::') ? cppClassDecls(owner, context)[0] : undefined; - const own = ownClass ? cppMethodsOf(ownClass, spelled, at, context) : []; - if (own.length > 0) return cppReturnTypeOf(own, context); + // `NewIterator(…)` in a method, or in a gtest body: what the method of its + // class, or of a class it derives from, returns (cppImplicitThisMethod), + // before anything outside. It is looked up as on `this`: in a template, a + // bare call into a base that depends on its parameters compiles only + // through a using-declaration, like protobuf's `using + // RepeatedFieldWrapper::MutableRepeatedField;`. + const own = spelled.includes('::') ? undefined : cppImplicitThisMethod(spelled, 'this', caller, at, context); + if (own) return cppReturnTypeOf([own], context); // `f(…)`, `ns::f(…)`, `Class::f(…)`: looked up from the caller outwards. for (const scope of cppScopesWithin(cppParentScope(caller.qualifiedName))) { const fns = context.getNodesByQualifiedName(scope ? `${scope}::${spelled}` : spelled).filter(isCppFunctionNode); @@ -10017,6 +10024,23 @@ function matchCppDeclaredTypeCall( return cppLacksMethod(found.node, context) ? null : undefined; } +/** + * Is a C++ `->` call on `receiver` made on a value — not a pointer — of a + * type from outside the project that nothing the file sees declares + * (isCppOutsideQualifiedType)? Its `operator->` hands the call to a type the + * declared one only wraps (`absl::StatusOr`'s `Any`), so the declared + * type's last name names no class the method is in. (A standard smart + * pointer or optional is matchCppHeldTypeCall's.) + */ +function isCppArrowThroughOutsideType(receiver: string, decl: CppReceiverDeclaration, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (!decl.callers || decl.shadowed || !decl.raw || decl.pointer || decl.aliased) return false; + if (cppMemberOperator(receiver, ref, context) !== '->' || isCppLibraryType(decl.raw, context)) return false; + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller) return false; + const site = decl.declared ?? cppCallSite(caller, ref, context); + return cppClassWritten(decl.raw, site, ref, context) === undefined && isCppOutsideQualifiedType(decl.raw, site, context); +} + /** * Is the C++ type `raw`, which nothing the file sees declares, written under * a namespace the project declares no type in — `absl::Status`, @@ -10148,16 +10172,33 @@ function matchCppImplicitThisCall(ref: UnresolvedRef, context: ResolutionContext const receiver = cppImplicitReceiver(ref, context); if (receiver === null) return undefined; const caller = context.getNodeById?.(ref.fromNodeId); - const owner = caller ? cppMemberOwner(caller, context) : null; - if (!caller || owner === null) return undefined; + if (!caller) return undefined; + const found = cppImplicitThisMethod(name, receiver, caller, ref, context); + // (A recursive call names the function itself, which no local shadows.) + if (!found || (receiver === 'bare' && name !== caller.name && isCppLocalName(name, caller, ref, context))) return undefined; + return { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'instance-method' }; +} + +/** + * The method a C++ call of `name` written with no receiver, or on `this`, + * reaches from `caller` by C++ lookup (matchCppImplicitThisCall): of the + * caller's class, a class it derives from, or one it is nested in. A gtest + * `TEST_F(Fixture, …)` or `TEST_P` body is a method of a class gtest derives + * from `Fixture`, so the fixture's methods are its own: rocksdb's tests call + * `DBTestBase`'s `Put(…)` and `NewIterator(…)` bare. Undefined when the + * caller is no member function, or no class on the way has a method of that + * name in the index. (Whether a local of the caller's shadows it is the + * caller's to ask.) + */ +function cppImplicitThisMethod(name: string, receiver: 'bare' | 'this', caller: Node, ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + const owner = cppMemberOwner(caller, context) ?? cppTestFixture(caller, ref, context); + if (owner === null) return undefined; let found: Node | null | undefined = null; for (const scope of cppEnclosingClasses(owner, caller, context)) { found = cppScopeMethod(scope, name, receiver, ref, context); if (found !== null || receiver === 'this') break; } - // (A recursive call names the function itself, which no local shadows.) - if (!found || (receiver === 'bare' && name !== caller.name && isCppLocalName(name, caller, ref, context))) return undefined; - return { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'instance-method' }; + return found ?? undefined; } /** `this->` / `(*this).` before a member's name, with a `template` keyword after it. */ @@ -12555,6 +12596,11 @@ export function matchMethodCall( const declared = nmTimedT('mc-cppdecl', ref, () => matchCppDeclaredTypeCall(objectOrClass!, methodName!, cppDecl, ref, context)); if (declared !== undefined) return declared; + // `->` on a value of a type from outside the project reaches what its + // `operator->` returns, which the type's last name doesn't name: + // protobuf's `absl::StatusOr m; m->value()` is no member of the + // project's own `hpb::StatusOr`. The name strategies decide. + if (inferredType && isCppArrowThroughOutsideType(objectOrClass!, cppDecl, ref, context)) inferredType = null; } // A pytest test's parameter is what its fixture returns: flaskbb's // `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`. From 0cea43e9ba7328831328c60507a954f09da413f1 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:43:42 +0000 Subject: [PATCH 04/29] fix(cpp): `->` on an absl::StatusOr reaches the class it holds; a using-declared member of a dependent base is found (#2539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in C++ member lookup. In protobuf, `absl::StatusOr resolver = …; resolver->MergeFeatures(…)` was guessed by the method's name: the declared type's last name, `StatusOr`, is no class of the project's, and only std's smart pointers and optional were followed to what they hold. In rocksdb's typed_cache.h, `using BasicTypedCacheHelperFns::DownCastValue;` makes a bare `DownCastValue(v)` the base's, but a bare name skipped every base that depends on the class's template parameters. - cppHeldType (was cppStdHeldType) also reads `absl::StatusOr`, `absl::optional` and `absl::NoDestructor` as holders, and matchCppHeldTypeCall takes one whose name no project type has (isCppLibraryHolder) — std's holders as before. - cppUsingDeclares reads the `using Base::name;` declarations a class body writes. cppScopeMethod and cppInheritedDataMember look a bare name up in a dependent base when the class brings it in from that base. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-absl-holder-receiver.test.ts | 213 +++++++++++++++++++++ src/resolution/name-matcher.ts | 85 ++++++-- 3 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 __tests__/cpp-absl-holder-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9641befea4..7060940d77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a variable, parameter or member whose declaration puts a qualifier or an annotation around its name is now read for its type, so calls on it link to the declared class's methods: a `const` after the `*` or `&`, like facebook/rocksdb's `Iterator* const iter = iter_guard.get();` or google/leveldb's `Env* const env_;`, a type written before its `const`, like `Slice const& key`, a nullability annotation after the `*`, like protocolbuffers/protobuf's `io::EpsCopyOutputStream* PROTOBUF_NONNULL stream`, and a thread-safety annotation after the name, like LevelDB's `MemTable* imm_ GUARDED_BY(mutex_);`. Before, such a declaration was skipped, or the annotation was taken for its type, so the call took the type of an earlier variable of the same name or was guessed by the method's name: RocksDB's `iter->SeekToFirst()` reached the unrelated `ArenaWrappedDBIter`, and LevelDB's `env_->NewWritableFile(…)` reached `InMemoryEnv`. Re-index C++ projects after upgrading. - In C++, a call on a name a structured binding declares now links to the method of the type C++ gives it. That covers a range-based `for` over pairs or a map, like facebook/rocksdb's `for (auto& [cfh, iter] : cfh_iter_pairs_) { iter->Valid(); }`, and a declaration like protocolbuffers/protobuf's `auto [dst_ext, is_new] = Insert(…);`. A call on what a `std::function` or another function wrapper returns, like `create_iterator()->Valid()` on a `std::function`, links the same way, and so does a call on a class member whose type is written on the line above its name. Before, these calls were guessed by the method's name, and calls on a library container declared that way, like a `std::priority_queue` member's `pop()`, reached a project class's method of that name. Re-index C++ projects after upgrading. - In C++, a call written without a receiver in a gtest `TEST_F` or `TEST_P` body now links to the test fixture's method, its own or one it inherits, as C++ finds it: facebook/rocksdb's tests call `DBTestBase`'s `Put(…)`, `Flush()` and `NewIterator(…)` this way. A variable such a call initializes, like `auto iter = NewIterator(…)`, takes the type the method returns, so `iter->SeekToFirst()` reaches the iterator class. Before, those calls were guessed by name and often reached a namesake in another class, like RocksDB's `ColumnFamilyTestBase::Put` or protocolbuffers/protobuf's `EncodeDecodeTest::ExpectNoErrors`, and `iter->SeekToFirst()` reached the unrelated `MemTableRep::Iterator`. A `->` call on a value of a type from outside the project, like protobuf's `m->value()` on an `absl::StatusOr`, no longer links to a class of the project that shares the type's name, like protobuf's own `hpb::StatusOr`. Re-index C++ projects after upgrading. +- In C++, a call made with `->` on an `absl::StatusOr`, an `absl::optional` or an `absl::NoDestructor`, like protocolbuffers/protobuf's `resolver->MergeFeatures(…)` on an `absl::StatusOr`, now links to the method of the class it holds, as one on a `std::unique_ptr` or `std::optional` does. A call written without a receiver in a class template now also finds a member of a base that depends on the template's parameters when the class brings it in with a using-declaration, like facebook/rocksdb's `using BasicTypedCacheHelperFns::DownCastValue;`, and so does a call on such a member. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-absl-holder-receiver.test.ts b/__tests__/cpp-absl-holder-receiver.test.ts new file mode 100644 index 0000000000..d656db6922 --- /dev/null +++ b/__tests__/cpp-absl-holder-receiver.test.ts @@ -0,0 +1,213 @@ +/** + * A C++ `->` call on an `absl::StatusOr`, an `absl::optional` or an + * `absl::NoDestructor` calls a member of the type it holds, as one on a + * `std::unique_ptr` or `std::optional` does; and a bare call in a class + * template finds a member its dependent base declares when the class brings + * the name in with a using-declaration. + * + * In protocolbuffers/protobuf, `absl::StatusOr resolver = + * FeatureResolver::Create(…); resolver->MergeFeatures(…)` was guessed by the + * method's name: the declared type's last name, `StatusOr`, is no class of + * the project's. In facebook/rocksdb's typed_cache.h, `using + * BasicTypedCacheHelperFns::DownCastValue;` makes a bare + * `DownCastValue(v)` the base's, which C++ doesn't look in otherwise: the + * base depends on the class's template parameters. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-absl-held-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName @confidence`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +// Two classes with a method of each name, so a guess by the name can miss. +const RESOLVERS = lines( + 'namespace google {', + 'namespace protobuf {', + 'class FeatureResolver {', + ' public:', + ' int MergeFeatures(int parent, int child) const { return parent; }', + '};', + 'class FeatureSetResolver {', + ' public:', + ' int MergeFeatures(int parent, int child) const { return child; }', + '};', + '} // namespace protobuf', + '} // namespace google', +); + +describe('C++ absl holders and using-declared members of dependent bases', () => { + it("is the held class's method for `->` on an absl::StatusOr, absl::optional or absl::NoDestructor", async () => { + const cg = await indexed({ + 'src/feature_resolver.h': RESOLVERS, + 'src/feature_resolver_test.cc': lines( + '#include "src/feature_resolver.h"', + 'namespace google {', + 'namespace protobuf {', + 'int Merge(absl::StatusOr resolver) {', + ' return resolver->MergeFeatures(1, 2);', + '}', + 'int MergeOptional(absl::optional resolver) {', + ' return resolver->MergeFeatures(1, 2);', + '}', + 'int MergeStatic() {', + ' static absl::NoDestructor resolver;', + ' return resolver->MergeFeatures(1, 2);', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + for (const caller of ['Merge', 'MergeOptional', 'MergeStatic']) { + expect(calls(cg, `google::protobuf::${caller}`)).toEqual(['google::protobuf::FeatureResolver::MergeFeatures @0.9']); + } + } finally { + cg.close(); + } + }); + + it("is not a project's own StatusOr, nor what a StatusOr of a pointer holds", async () => { + const cg = await indexed({ + 'src/feature_resolver.h': RESOLVERS, + // protobuf's hpb declares a `StatusOr` of its own. + 'hpb/status.h': lines( + 'namespace absl {', + 'template ', + 'class StatusOr {', + ' public:', + ' T value() const;', + '};', + '} // namespace absl', + ), + 'src/use.cc': lines( + '#include "src/feature_resolver.h"', + '#include "hpb/status.h"', + 'namespace google {', + 'namespace protobuf {', + 'int Merge(absl::StatusOr resolver) {', + ' return resolver->MergeFeatures(1, 2);', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::Merge').filter((c) => c.startsWith('google::protobuf::FeatureResolver::') && c.endsWith('@0.9'))).toEqual([]); + } finally { + cg.close(); + } + }); + + it('is the member of the base the using-declaration names, of two dependent bases', async () => { + const cg = await indexed({ + 'util/runner.h': lines( + 'namespace rocksdb {', + 'template ', + 'class Second {', + ' public:', + ' void Run() {}', + '};', + 'template ', + 'class First {', + ' public:', + ' void Run() {}', + '};', + 'template ', + 'class Both : public Second, public First {', + ' public:', + ' using First::Run;', + ' void Go() { Run(); }', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Both::Go')).toEqual(['rocksdb::First::Run @0.9']); + } finally { + cg.close(); + } + }); + + it('is the dependent base member a using-declaration brings in, for a bare call and a data member', async () => { + const cg = await indexed({ + // (The base in a header of its own, where the derived class's file doesn't write its member.) + 'cache/typed_cache_base.h': lines( + 'namespace rocksdb {', + 'class Cache {', + ' public:', + ' int Insert(int key) { return key; }', + '};', + 'class Other {', + ' public:', + ' int Insert(int key) { return 0; }', + '};', + 'template ', + 'class BasicTypedCacheHelperFns {', + ' public:', + ' static TValue* DownCastValue(void* v) { return nullptr; }', + ' protected:', + ' Cache* cache_;', + '};', + '} // namespace rocksdb', + ), + 'cache/typed_cache.h': lines( + '#include "cache/typed_cache_base.h"', + 'namespace rocksdb {', + 'inline int* DownCastValue(void* v) { return nullptr; }', + 'template ', + 'class FullTypedCacheHelperFns : public BasicTypedCacheHelperFns {', + ' public:', + ' using BasicTypedCacheHelperFns::DownCastValue;', + ' using BasicTypedCacheHelperFns::cache_;', + ' static void Delete(void* v) {', + ' DownCastValue(v);', + ' }', + ' int Store(int key) {', + ' return cache_->Insert(key);', + ' }', + '};', + '} // namespace rocksdb', + ), + }); + try { + // (Without the using-declarations C++ finds the namespace's `DownCastValue`, as before.) + expect(calls(cg, 'rocksdb::FullTypedCacheHelperFns::Delete')).toEqual(['rocksdb::BasicTypedCacheHelperFns::DownCastValue @0.9']); + expect(calls(cg, 'rocksdb::FullTypedCacheHelperFns::Store')).toEqual(['rocksdb::Cache::Insert @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index fa0f6498e9..abd407c770 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9374,7 +9374,8 @@ function cppInheritedDataMember(decls: readonly Node[], name: string, context: R if (depth >= 4 || !context.getSupertypeNodes) return null; for (const decl of decls) { for (const base of context.getSupertypeNodes(decl.id)) { - if (!CPP_CLASS_KINDS.has(base.kind) || seen.has(base.qualifiedName) || isCppDependentBase(decl, base, context)) continue; + if (!CPP_CLASS_KINDS.has(base.kind) || seen.has(base.qualifiedName)) continue; + if (isCppDependentBase(decl, base, context) && !cppUsingDeclares(decl, base, name, context)) continue; seen.add(base.qualifiedName); const inherited = cppInheritedDataMember([base], name, context, depth + 1, seen); if (inherited) return inherited; @@ -9402,7 +9403,7 @@ function cppTestFixture(caller: Node, ref: UnresolvedRef, context: ResolutionCon /** * The type `*x` has when `x` has the type `type`: what a pointer points at, - * or what a standard smart pointer or optional holds. Null for any other type. + * or what a smart pointer, an optional or an `absl::StatusOr` holds (cppHeldType). Null for any other type. */ function cppPointee(type: CppWrittenType): CppWrittenType | null { const text = type.text.replace(/&+\s*$/, '').trim(); @@ -9413,7 +9414,7 @@ function cppPointee(type: CppWrittenType): CppWrittenType | null { else if (ch === '<') depth--; else if (ch === '*' && depth === 0) return { ...type, text: text.slice(0, i).trim() }; } - const held = cppStdHeldType(text); + const held = cppHeldType(text); return held ? { ...type, text: held } : null; } @@ -9531,8 +9532,8 @@ function cppExpressionType(expr: string, at: UnresolvedRef, context: ResolutionC // `std::make_unique(…)`: the smart pointer it makes. const made = /^(?:::\s*)?(?:std|absl)\s*::\s*make_(unique|shared)\s*<([\s\S]+)>$/.exec(callee); if (made) return { ...callers, text: `std::${made[1]}_ptr<${made[2]!.trim()}>` }; - // `std::unique_ptr(…)`: the smart pointer or optional it makes. - if (cppStdHeldType(callee)) return { ...callers, text: callee }; + // `std::unique_ptr(…)`: the holder it makes (cppHeldType). + if (cppHeldType(callee)) return { ...callers, text: callee }; // `create_iterator()` on a `std::function create_iterator`: what its function type returns. if (/^[A-Za-z_]\w*$/.test(callee)) { const wrapper = cppExpressionType(callee, at, context, depth + 1); @@ -9648,7 +9649,7 @@ function cppAutoLocalType( function isCppTemplateDependentType(type: CppWrittenType, context: ResolutionContext): boolean { if (type.parameters.size === 0) return false; if (isCppTemplateParameterType(type)) return true; - const held = cppStdHeldType(type.text); + const held = cppHeldType(type.text); if (held) return isCppTemplateDependentType({ ...type, text: held }, context); // (A trait of one, `std::conditional_t`, too: it names another type.) return (isCppLibraryType(type.text, context) || cppTypeSegments(type.text)?.[0] === 'std') && @@ -9899,19 +9900,31 @@ function isCppLibraryType(raw: string, context: ResolutionContext): boolean { return !declared.some((n) => n.qualifiedName === spelled || n.qualifiedName.endsWith(`::${spelled}`)); } -/** Standard-library types whose `->` reaches the value they hold. */ -const CPP_STD_HOLDERS: ReadonlySet = new Set(['unique_ptr', 'shared_ptr', 'optional']); +/** Library types whose `->` reaches the value they hold. */ +const CPP_HOLDERS: ReadonlySet = new Set(['std::unique_ptr', 'std::shared_ptr', 'std::optional', 'absl::optional', 'absl::StatusOr', 'absl::NoDestructor']); -/** The type a standard smart pointer or optional written as `raw` holds (`Iterator` for `std::unique_ptr`), or null. */ -function cppStdHeldType(raw: string): string | null { +/** + * The type a standard smart pointer, an optional, an `absl::StatusOr` or an + * `absl::NoDestructor` written as `raw` holds (`Iterator` for + * `std::unique_ptr`), or null. + */ +function cppHeldType(raw: string): string | null { const holder = cppTypeSegments(raw); - if (holder?.length !== 2 || holder[0] !== 'std' || !CPP_STD_HOLDERS.has(holder[1]!)) return null; + if (holder?.length !== 2 || !CPP_HOLDERS.has(holder.join('::'))) return null; return cppTemplateArguments(raw)?.[0] ?? null; } +/** Whether the holder `raw` names (cppHeldType) is the library's: no type of the project's has its name. */ +function isCppLibraryHolder(raw: string, context: ResolutionContext): boolean { + const spelled = cppTypeSegments(raw)!.join('::'); + return !cppTypesNamed(cppLastSegment(spelled), context).some((n) => n.qualifiedName === spelled || n.qualifiedName.endsWith(`::${spelled}`)); +} + /** - * A `->` call on a C++ receiver declared as a standard smart pointer or - * optional (`std::unique_ptr iter; iter->Valid()`) calls a member of + * A `->` call on a C++ receiver declared as a standard smart pointer, an + * optional or an `absl::StatusOr` (`std::unique_ptr iter; + * iter->Valid()`, protobuf's `absl::StatusOr resolver; + * resolver->MergeFeatures(…)`) calls a member of * the type it holds. That type is looked up as C++ looks it up where the call * is written (cpp-supertypes.ts): the scopes around the call, the file's * `using`s, and aliases followed. Looking it up by its last name instead @@ -9929,8 +9942,8 @@ function cppStdHeldType(raw: string): string | null { * doesn't name): the call goes on as before. */ function matchCppHeldTypeCall(raw: string, methodName: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { - const held = cppStdHeldType(raw); - if (!held || !isCppLibraryType(raw, context)) return undefined; + const held = cppHeldType(raw); + if (!held || !isCppLibraryHolder(raw, context)) return undefined; const caller = context.getNodeById?.(ref.fromNodeId); if (!caller || isCppPointerType(held)) return undefined; if (isCppLibraryType(held, context)) return null; @@ -10029,8 +10042,9 @@ function matchCppDeclaredTypeCall( * type from outside the project that nothing the file sees declares * (isCppOutsideQualifiedType)? Its `operator->` hands the call to a type the * declared one only wraps (`absl::StatusOr`'s `Any`), so the declared - * type's last name names no class the method is in. (A standard smart - * pointer or optional is matchCppHeldTypeCall's.) + * type's last name names no class the method is in. (A smart pointer, an + * optional or an `absl::StatusOr` holding one of the project's classes is + * matchCppHeldTypeCall's.) */ function isCppArrowThroughOutsideType(receiver: string, decl: CppReceiverDeclaration, ref: UnresolvedRef, context: ResolutionContext): boolean { if (!decl.callers || decl.shadowed || !decl.raw || decl.pointer || decl.aliased) return false; @@ -10375,7 +10389,7 @@ function cppScopeMethod(scope: CppClassScope, name: string, receiver: 'bare' | ' for (const decl of scope.decls) { for (const base of context.getSupertypeNodes(decl.id)) { if (!CPP_CLASS_KINDS.has(base.kind)) continue; - if (receiver === 'bare' && isCppDependentBase(decl, base, context)) continue; + if (receiver === 'bare' && isCppDependentBase(decl, base, context) && !cppUsingDeclares(decl, base, name, context)) continue; const inherited = cppMethodOf(base, name, ref, context, 1, new Set([scope.qualifiedName])); if (!inherited) continue; // Its class's other overloads, which the arguments may fit better @@ -10463,10 +10477,37 @@ function cppBaseSpecifiers(decl: Node, context: ResolutionContext): string[] { return specs; } +/** Per context: a C++ class declaration's id → the `using Base::name;` declarations its body writes, as [base, name]. */ +const CPP_USING_MEMBERS = new WeakMap>>(); + +/** + * Whether the C++ class declaration `decl` brings `name` in from its base + * `base` with a using-declaration: rocksdb's `FullTypedCacheHelperFns` + * writes `using BasicTypedCacheHelperFns::DownCastValue;`, and a bare + * `DownCastValue(v)` in it then finds the base's, though the base depends on + * the class's template parameters (isCppDependentBase). + */ +function cppUsingDeclares(decl: Node, base: Node, name: string, context: ResolutionContext): boolean { + let memo = CPP_USING_MEMBERS.get(context); + if (!memo) CPP_USING_MEMBERS.set(context, (memo = new Map())); + let usings = memo.get(decl.id); + if (!usings) { + usings = []; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + const body = lines.slice(decl.startLine - 1, decl.endLine ?? decl.startLine).map((l) => cppCodeOf(l) ?? '').join('\n'); + for (const m of body.matchAll(/\busing\s+(?!namespace\b)((?:::\s*)?[A-Za-z_][\w:]*(?:\s*<[^;{}]*>)?)\s*::\s*([A-Za-z_]\w*)\s*;/g)) { + usings.push([cppLastSegment(stripCppTemplateArguments(m[1]!).trim()), m[2]!]); + } + memo.set(decl.id, usings); + } + return usings.some(([of, member]) => of === base.name && member === name); +} + /** * Whether `base`, a base of the C++ class declaration `decl`, depends on the * template parameters of `decl` or a class around it: `template - * class Foo : public Base`. A bare name is not looked up in such a base. + * class Foo : public Base`. A bare name is not looked up in such a base, + * unless the class brings it in with a using-declaration (cppUsingDeclares). */ function isCppDependentBase(decl: Node, base: Node, context: ResolutionContext): boolean { const parameters = cppTemplateParameters(decl, context); @@ -11406,6 +11447,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { clearCppRawStringMemos(context); CPP_CLASSES_IN_FILE.delete(context); CPP_BASE_SPECS.delete(context); + CPP_USING_MEMBERS.delete(context); CPP_MEMBER_MACRO_ARGS.delete(context); CPP_LOOP_ELEMENTS.delete(context); CPP_AUTO_LOCALS.delete(context); @@ -12712,8 +12754,9 @@ export function matchMethodCall( // followed. if (isUndeclaredTypeName(cppDecl.written!, ref, context)) return null; } - // A `->` call on a standard smart pointer or optional the calling - // function or its class declares is a call on the type it holds: + // A `->` call on a smart pointer, an optional or an `absl::StatusOr` + // (cppHeldType) the calling function or its class declares is a call on + // the type it holds: // rocksdb's `std::unique_ptr iter; iter->Valid()` reached // `ArenaWrappedDBIter::Valid` by the method's name. if (ref.language === 'cpp' && cppDecl.callers && !cppDecl.aliased && !cppDecl.shadowed && !cppDecl.pointer && From 61cb4188a7a0fbd540d387a06fe03654a90829dd Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:44:46 +0000 Subject: [PATCH 05/29] fix(cpp): a call or type name never links to what only another source file can see (#2538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name matching linked C and C++ names to declarations no other translation unit can name: a class or alias a `.cc` file declares for itself, its members, and what a source file's unnamed namespace declares. In rocksdb, other test files' bare `Put(…)`, `Flush()` and `dbfull()` reached column_family_test.cc's `ColumnFamilyTestBase` thousands of times, and inlineskiplist.h's `Node*` receivers reached the `Node` hash_linklist_rep.cc declares in `namespace { … }`. - isVisibleAcrossFiles rejects such a candidate for a C or C++ ref (isCppTranslationUnitLocal), as it does a `static` C function: a type only source files declare; a member written inside such a class; an out-of-line member of a type the member's own file sees only source files declare (a scope its file sees no type of may be a namespace: fmt's posix-mock-test.cc defines posix-mock.h's `test::close` beside ostream-test.cc's own `struct test`); and, for the rest, what an unnamed namespace of a source file holds. An alias of the scope keeps the member, since the class may be declared anywhere. - A source file's declarations are still seen by the file itself, by a file that includes it, and by a header it includes below them (rocksdb's version_set.cc includes version_set_sync_and_async.h after its `FilePicker` classes). - The unnamed-namespace spans come from a scanner that skips comments, preprocessor lines (continued past a CRLF too) and string, character and raw string literals. - The typed lookups (cppMethodOf, resolveMethodOnType) take the candidates the call's file can see first (cppVisibleFirst): rocksdb's db_stress_shared_state.h and two benchmark `.cc` files each declare a `SharedState`, and a call in db_stress_common.cc is the header's. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-translation-unit-visibility.test.ts | 473 ++++++++++++++++++ src/resolution/name-matcher.ts | 198 +++++++- 3 files changed, 664 insertions(+), 8 deletions(-) create mode 100644 __tests__/cpp-translation-unit-visibility.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7060940d77..2f23fe8158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call on a name a structured binding declares now links to the method of the type C++ gives it. That covers a range-based `for` over pairs or a map, like facebook/rocksdb's `for (auto& [cfh, iter] : cfh_iter_pairs_) { iter->Valid(); }`, and a declaration like protocolbuffers/protobuf's `auto [dst_ext, is_new] = Insert(…);`. A call on what a `std::function` or another function wrapper returns, like `create_iterator()->Valid()` on a `std::function`, links the same way, and so does a call on a class member whose type is written on the line above its name. Before, these calls were guessed by the method's name, and calls on a library container declared that way, like a `std::priority_queue` member's `pop()`, reached a project class's method of that name. Re-index C++ projects after upgrading. - In C++, a call written without a receiver in a gtest `TEST_F` or `TEST_P` body now links to the test fixture's method, its own or one it inherits, as C++ finds it: facebook/rocksdb's tests call `DBTestBase`'s `Put(…)`, `Flush()` and `NewIterator(…)` this way. A variable such a call initializes, like `auto iter = NewIterator(…)`, takes the type the method returns, so `iter->SeekToFirst()` reaches the iterator class. Before, those calls were guessed by name and often reached a namesake in another class, like RocksDB's `ColumnFamilyTestBase::Put` or protocolbuffers/protobuf's `EncodeDecodeTest::ExpectNoErrors`, and `iter->SeekToFirst()` reached the unrelated `MemTableRep::Iterator`. A `->` call on a value of a type from outside the project, like protobuf's `m->value()` on an `absl::StatusOr`, no longer links to a class of the project that shares the type's name, like protobuf's own `hpb::StatusOr`. Re-index C++ projects after upgrading. - In C++, a call made with `->` on an `absl::StatusOr`, an `absl::optional` or an `absl::NoDestructor`, like protocolbuffers/protobuf's `resolver->MergeFeatures(…)` on an `absl::StatusOr`, now links to the method of the class it holds, as one on a `std::unique_ptr` or `std::optional` does. A call written without a receiver in a class template now also finds a member of a base that depends on the template's parameters when the class brings it in with a using-declaration, like facebook/rocksdb's `using BasicTypedCacheHelperFns::DownCastValue;`, and so does a call on such a member. Re-index C++ projects after upgrading. +- In C and C++, a call or a type name no longer links to something only another source file can see: a class, struct or alias a `.c` or `.cc` file declares for itself, its members, or anything inside that file's unnamed `namespace { … }`. In facebook/rocksdb, other test files' bare `Put(…)`, `Flush()` and `dbfull()` calls had linked to the methods of a test fixture declared in `column_family_test.cc`, and calls on an `Env*` had linked to a test-only environment class in `memenv.cc`. When a header and a source file each declare a class of the same name, like RocksDB's `SharedState`, a call now links to the header's. A source file's own declarations are still found from the file itself, from a file that `#include`s it, and from a header it includes below them. Re-index C and C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-translation-unit-visibility.test.ts b/__tests__/cpp-translation-unit-visibility.test.ts new file mode 100644 index 0000000000..714d71891b --- /dev/null +++ b/__tests__/cpp-translation-unit-visibility.test.ts @@ -0,0 +1,473 @@ +/** + * What a C or C++ source file declares for itself is no target of a name + * written in another file. + * + * A class or alias a `.cc` file declares, and whatever its unnamed namespace + * (`namespace { … }`) declares, belongs to that translation unit: another + * file can't name it. Name matching took them anyway. In facebook/rocksdb, + * other test files' bare `Put(…)` reached column_family_test.cc's own + * `ColumnFamilyTestBase::Put` 7,989 times, and inlineskiplist.h's `Node*` + * receivers reached the `Node` hash_linklist_rep.cc declares in an unnamed + * namespace. A source file's declarations are still seen by the file itself, + * by a file that includes it, and by a header it includes after them + * (rocksdb's version_set.cc includes version_set_sync_and_async.h below its + * `FilePicker` classes). + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-tu-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +/** The function or method `qualifiedName`, the one in `file` when given. */ +function callable(cg: CodeGraph, qualifiedName: string, file?: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find( + (n) => n.qualifiedName === qualifiedName && (file === undefined || n.filePath === file), + ); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName [file] @confidence`. */ +function calls(cg: CodeGraph, caller: string, file?: string): string[] { + return cg + .getCallees(callable(cg, caller, file).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} [${r.node.filePath}] @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +describe('C/C++ declarations local to another translation unit', () => { + it("is not another source file's class in an unnamed namespace", async () => { + const cg = await indexed({ + 'memtable/hash_linklist_rep.cc': lines( + 'namespace rocksdb {', + 'namespace {', + 'struct Node {', + ' Node* Next() { return nullptr; }', + '};', + '} // namespace', + '} // namespace rocksdb', + ), + 'memtable/inlineskiplist.h': lines( + 'namespace rocksdb {', + 'template ', + 'class InlineSkipList {', + ' private:', + ' struct Node;', + ' public:', + ' void Insert() {', + ' Node* x = head_;', + ' x->Next();', + ' }', + ' private:', + ' Node* head_;', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::InlineSkipList::Insert').filter((c) => c.includes('hash_linklist_rep.cc'))).toEqual([]); + } finally { + cg.close(); + } + }); + + it("is not another test file's fixture method a bare call names", async () => { + const cg = await indexed({ + 'db/column_family_test.cc': lines( + 'namespace rocksdb {', + 'class ColumnFamilyTestBase {', + ' public:', + ' void Put(int key) {}', + '};', + '} // namespace rocksdb', + ), + 'db/db_basic_test.cc': lines( + 'namespace rocksdb {', + 'void Put(int key);', + 'void Check() {', + ' Put(1);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Check').filter((c) => c.includes('column_family_test.cc'))).toEqual([]); + } finally { + cg.close(); + } + }); + + it("is not a function another source file's unnamed namespace declares", async () => { + const cg = await indexed({ + 'conformance/matchers.cc': lines( + 'namespace conformance {', + 'namespace {', + 'int RawPayload(int response) { return response; }', + '} // namespace', + '} // namespace conformance', + ), + 'conformance/matchers_test.cc': lines( + 'namespace conformance {', + 'int Describe() {', + ' return RawPayload(1);', + '}', + '} // namespace conformance', + ), + }); + try { + expect(calls(cg, 'conformance::Describe').filter((c) => c.includes('matchers.cc'))).toEqual([]); + } finally { + cg.close(); + } + }); + + it("is a member of the header's class, not of a source file's namesake", async () => { + // rocksdb declares a `SharedState` in db_stress_shared_state.h and two + // more of its own in cache_bench_tool.cc and db_bench_tool.cc. + const cg = await indexed({ + 'cache/cache_bench_tool.cc': lines( + 'namespace rocksdb {', + 'class SharedState {', + ' public:', + ' int* GetMutex() { return nullptr; }', + '};', + '} // namespace rocksdb', + ), + 'db_stress_tool/db_stress_shared_state.h': lines( + 'namespace rocksdb {', + 'class SharedState {', + ' public:', + ' int* GetMutex() { return nullptr; }', + '};', + '} // namespace rocksdb', + ), + 'db_stress_tool/db_stress_common.cc': lines( + '#include "db_stress_tool/db_stress_shared_state.h"', + 'namespace rocksdb {', + 'void Lock(SharedState* shared) {', + ' shared->GetMutex();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Lock')).toEqual(['rocksdb::SharedState::GetMutex [db_stress_tool/db_stress_shared_state.h] @0.9']); + } finally { + cg.close(); + } + }); + + it("is the header's class for a receiver a namespace-scope variable holds", async () => { + const cg = await indexed({ + 'cache/cache_bench_tool.cc': lines( + 'namespace rocksdb {', + 'class SharedState {', + ' public:', + ' int* GetMutex() { return nullptr; }', + '};', + '} // namespace rocksdb', + ), + 'db_stress_tool/db_stress_shared_state.h': lines( + 'namespace rocksdb {', + 'class SharedState {', + ' public:', + ' int* GetMutex() { return nullptr; }', + '};', + '} // namespace rocksdb', + ), + 'db_stress_tool/db_stress_common.cc': lines( + '#include "db_stress_tool/db_stress_shared_state.h"', + 'namespace rocksdb {', + 'SharedState* shared_state = nullptr;', + 'void LockShared() {', + ' shared_state->GetMutex();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::LockShared')).toEqual(['rocksdb::SharedState::GetMutex [db_stress_tool/db_stress_shared_state.h] @0.9']); + } finally { + cg.close(); + } + }); + + it("is a header's class, with no include the index follows", async () => { + const cg = await indexed({ + 'include/rocksdb/cache.h': lines( + 'namespace rocksdb {', + 'class Cache {', + ' public:', + ' virtual int Insert(int key) { return key; }', + '};', + '} // namespace rocksdb', + ), + 'cache/user.cc': lines( + 'namespace rocksdb {', + 'int Use(Cache* cache) {', + ' return cache->Insert(1);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Use')).toEqual(['rocksdb::Cache::Insert [include/rocksdb/cache.h] @0.9']); + } finally { + cg.close(); + } + }); + + it("is not an out-of-line method of a class another source file declares", async () => { + // protobuf's descriptor.cc declares `FileDescriptorTables` outside its + // unnamed namespaces and defines the class's methods below it. + const cg = await indexed({ + 'src/descriptor.cc': lines( + 'namespace google {', + 'namespace protobuf {', + 'class FileDescriptorTables {', + ' public:', + ' const int* FindFieldByLowercaseName(const char* name) const;', + '};', + 'const int* FileDescriptorTables::FindFieldByLowercaseName(const char* name) const {', + ' return nullptr;', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + 'python/message.cc': lines( + 'namespace google {', + 'namespace protobuf {', + 'namespace python {', + 'const int* Find(CMessage* self, const char* name) {', + ' return self->message_descriptor->FindFieldByLowercaseName(name);', + '}', + '} // namespace python', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::python::Find')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('is not a class another source file declares, named as a type', async () => { + // rocksdb's db/log_test.cc declares a `StringSource` of its own; other + // tests' `new test::StringSource(…)` are test_util's. + const cg = await indexed({ + 'db/log_test.cc': lines( + 'namespace rocksdb {', + 'namespace log {', + 'class StringSource {', + ' public:', + ' explicit StringSource(int contents) {}', + '};', + '} // namespace log', + '} // namespace rocksdb', + ), + 'db/plain_table_db_test.cc': lines( + 'namespace rocksdb {', + 'void* Make() {', + ' return new test::StringSource(1);', + '}', + '} // namespace rocksdb', + ), + }); + try { + const make = callable(cg, 'rocksdb::Make'); + expect(cg.getOutgoingEdgesFrom([make.id]).map((e) => cg.getNode(e.target)?.filePath).filter((f) => f === 'db/log_test.cc')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('reads an unnamed namespace past comments, preprocessor lines and literals that spell one', async () => { + const cg = await indexed({ + 'db/c.cc': lines( + 'namespace rocksdb {', + '// namespace { in a line comment', + '/* namespace { in a block comment */', + '#define ROCKSDB_ANONYMOUS_BEGIN namespace {', + 'const char* kDoc = "namespace {";', + 'const char* kRaw = R"(a "b namespace {" c)";', + 'int Helper(int x) { return x; }', + '} // namespace rocksdb', + ), + // A macro continued onto the next line, in a file checked out with CRLF. + 'db/crlf.cc': ['namespace rocksdb {', '#define ROCKSDB_ANONYMOUS_BEGIN \\', ' namespace {', 'int Other(int x) { return x; }', '} // namespace rocksdb', ''].join('\r\n'), + 'db/c_test.cc': lines( + 'namespace rocksdb {', + 'int Check() {', + ' return Helper(1) + Other(2);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Check')).toEqual(['rocksdb::Helper [db/c.cc] @0.9', 'rocksdb::Other [db/crlf.cc] @0.9']); + } finally { + cg.close(); + } + }); + + it("is still the file's own, and a header's it includes after the declaration", async () => { + const cg = await indexed({ + 'db/version_set.cc': lines( + 'namespace rocksdb {', + 'namespace {', + 'class FilePicker {', + ' public:', + ' int GetNextFile() { return 0; }', + '};', + '} // namespace', + 'int Pick(FilePicker& fp) {', + ' return fp.GetNextFile();', + '}', + '} // namespace rocksdb', + '#include "db/version_set_sync_and_async.h"', + ), + 'db/version_set_sync_and_async.h': lines( + 'namespace rocksdb {', + 'int PickAsync(FilePicker& fp) {', + ' return fp.GetNextFile();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Pick')).toEqual(['rocksdb::FilePicker::GetNextFile [db/version_set.cc] @0.9']); + expect(calls(cg, 'rocksdb::PickAsync')).toEqual(['rocksdb::FilePicker::GetNextFile [db/version_set.cc] @0.9']); + } finally { + cg.close(); + } + }); + + it('is seen by a file that includes the source file', async () => { + const cg = await indexed({ + 'util/impl.cc': lines( + 'namespace {', + 'class Impl {', + ' public:', + ' int Run() { return 0; }', + '};', + '} // namespace', + ), + 'util/impl_test.cc': lines( + '#include "util/impl.cc"', + 'int Test(Impl& impl) {', + ' return impl.Run();', + '}', + ), + }); + try { + expect(calls(cg, 'Test')).toEqual(['Impl::Run [util/impl.cc] @0.9']); + } finally { + cg.close(); + } + }); + + it('keeps an out-of-line method of a class a header declares, through an alias too', async () => { + const cg = await indexed({ + 'include/rocksdb/multi_scan.h': lines( + 'namespace rocksdb {', + 'class MultiScan {', + ' public:', + ' class MultiScanIterator {', + ' public:', + ' void SeekCurrentRange();', + ' void Next() { SeekCurrentRange(); }', + ' };', + '};', + 'class Table {', + ' public:', + ' int Open();', + '};', + '} // namespace rocksdb', + ), + 'db/multi_scan.cc': lines( + '#include "include/rocksdb/multi_scan.h"', + 'namespace rocksdb {', + 'using MultiScanIterator = MultiScan::MultiScanIterator;', + 'void MultiScanIterator::SeekCurrentRange() {}', + 'int Table::Open() { return 0; }', + '} // namespace rocksdb', + ), + 'db/open.cc': lines( + '#include "include/rocksdb/multi_scan.h"', + 'namespace rocksdb {', + 'int OpenTable(Table* t) {', + ' return t->Open();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::MultiScan::MultiScanIterator::Next').filter((c) => c.includes('SeekCurrentRange'))).toHaveLength(1); + expect(calls(cg, 'rocksdb::OpenTable')).toEqual(['rocksdb::Table::Open [db/multi_scan.cc] @0.9']); + } finally { + cg.close(); + } + }); + + it("keeps a namespace's function a source file defines out of line, beside another file's namesake struct", async () => { + // fmt's posix-mock-test.cc defines posix-mock.h's `test::close`, and + // ostream-test.cc declares a `struct test` of its own. + const cg = await indexed({ + 'test/posix-mock.h': lines('namespace test {', 'int close(int fildes);', '} // namespace test'), + 'test/posix-mock-test.cc': lines('#include "posix-mock.h"', 'int test::close(int fildes) { return fildes; }'), + 'test/ostream-test.cc': lines('struct test {};'), + 'test/os-test.cc': lines('#include "posix-mock.h"', 'int Shut(int fd) {', ' return test::close(fd);', '}'), + }); + try { + expect(calls(cg, 'Shut').filter((c) => c.startsWith('test::close'))).toHaveLength(1); + } finally { + cg.close(); + } + }); + + it("gives a class no base another source file declares", async () => { + const cg = await indexed({ + 'util/base.cc': lines( + 'namespace rocksdb {', + 'class Base {', + ' public:', + ' virtual void Run() {}', + '};', + '} // namespace rocksdb', + ), + 'util/derived.h': lines( + 'namespace rocksdb {', + 'class Derived : public Base {};', + '} // namespace rocksdb', + ), + }); + try { + const derived = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'rocksdb::Derived')!; + expect(cg.getOutgoingEdgesFrom([derived.id], ['extends']).map((e) => cg.getNode(e.target)?.filePath)).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index abd407c770..49e893e3cc 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1858,6 +1858,186 @@ const C_STATIC_MEMO = new WeakMap>(); */ const C_SOURCE_EXT = /\.(c|cc|cpp|cxx|c\+\+|m|mm)$/i; +/** Per context: a C/C++ type's qualified name → its declarations. */ +const CPP_TYPE_DECLS = new WeakMap>(); +/** Per context: a C/C++ file → the line and resolved file of each `#include` it writes. */ +const CPP_INCLUDE_LINES = new WeakMap>>(); +/** Per context: a C/C++ file → the line spans of its unnamed namespaces. */ +const CPP_UNNAMED_NAMESPACES = new WeakMap>>(); + +/** + * Whether only another C or C++ translation unit can name `candidate`: a + * type or alias a source file declares, a member of a class only source + * files declare, or what a source file's unnamed namespace (`namespace { … }`) + * declares. rocksdb's test fixture `ColumnFamilyTestBase` is declared in + * column_family_test.cc, and other test files' bare `Put(…)` reached its `Put` + * by name; inlineskiplist.h's `Node*` reached hash_linklist_rep.cc's own + * `Node`. What a source file declares is seen by the file itself, by a + * translation unit that includes it (a test that includes the source it + * tests), and by a header it includes after the declaration: rocksdb's + * version_set.cc includes version_set_sync_and_async.h below its + * `FilePicker` classes. + */ +function isCppTranslationUnitLocal(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (ref.language !== 'c' && ref.language !== 'cpp') return false; + const seenFrom = (decl: Node, file: string): boolean => !C_SOURCE_EXT.test(decl.filePath) || decl.filePath === file || + cppIncluders(decl.filePath, context).has(file) || cppIncludesAfter(decl.filePath, decl.startLine, file, context); + const seen = (decl: Node): boolean => seenFrom(decl, ref.filePath); + if (CPP_TYPE_KINDS.has(candidate.kind)) return !seen(candidate); + // A member written in its class's declaration is that declaration's; one + // defined out of line is the class's, wherever a declaration of it its own + // file sees is (through an alias, the class may be declared anywhere). A + // scope its file sees no type of may be a namespace: fmt's posix-mock-test.cc + // defines `test::close` of posix-mock.h's `namespace test`, and + // ostream-test.cc declares a `struct test` of its own. + const enclosing = cppEnclosingClassDecl(candidate, context); + if (enclosing) return !seen(enclosing); + const decls = cppTypeDecls(cppParentScope(candidate.qualifiedName), context) + .filter((d) => seenFrom(d, candidate.filePath)); + if (decls.some((d) => d.kind === 'type_alias')) return false; + if (decls.length > 0) return !decls.some(seen); + return !seen(candidate) && + cppUnnamedNamespaceSpans(candidate.filePath, context).some(([start, end]) => candidate.startLine >= start && candidate.startLine <= end); +} + +/** + * Of the methods a C or C++ type's name gives, those `ref`'s translation + * unit can see (isCppTranslationUnitLocal), when there are any. Classes of + * one qualified name can be declared apart: rocksdb's db_stress_shared_state.h + * and two benchmark `.cc` files each declare a `SharedState`, and a call in + * db_stress_common.cc is the header's. (Every candidate for any other ref.) + */ +function cppVisibleFirst(methods: Node[], ref: UnresolvedRef, context: ResolutionContext): Node[] { + if (methods.length < 2 || (ref.language !== 'c' && ref.language !== 'cpp')) return methods; + const visible = methods.filter((m) => !isCppTranslationUnitLocal(m, ref, context)); + return visible.length > 0 ? visible : methods; +} + +/** The declarations of the C or C++ type (or alias) named `qualifiedName`. */ +function cppTypeDecls(qualifiedName: string, context: ResolutionContext): Node[] { + if (!qualifiedName) return []; + let memo = CPP_TYPE_DECLS.get(context); + if (!memo) CPP_TYPE_DECLS.set(context, (memo = new Map())); + let decls = memo.get(qualifiedName); + if (!decls) { + decls = context.getNodesByQualifiedName(qualifiedName) + .filter((n) => CPP_TYPE_KINDS.has(n.kind) && (n.language === 'cpp' || n.language === 'c')); + memo.set(qualifiedName, decls); + } + return decls; +} + +/** The innermost C or C++ class, struct or union declaration of its file whose lines hold `member`. */ +function cppEnclosingClassDecl(member: Node, context: ResolutionContext): Node | undefined { + let innermost: Node | undefined; + for (const cls of cppClassesIn(member.filePath, context)) { + if (cls.id === member.id || member.startLine < cls.startLine || member.startLine > (cls.endLine ?? cls.startLine)) continue; + if (!innermost || cls.startLine >= innermost.startLine) innermost = cls; + } + return innermost; +} + +/** Whether the C or C++ file `source` `#include`s `header` below line `line`. */ +function cppIncludesAfter(source: string, line: number, header: string, context: ResolutionContext): boolean { + let memo = CPP_INCLUDE_LINES.get(context); + if (!memo) CPP_INCLUDE_LINES.set(context, (memo = new Map())); + let includes = memo.get(source); + if (!includes) { + includes = context.getNodesInFile(source) + .filter((n) => n.kind === 'import') + .map((n): [number, string | null] => [n.startLine, cppIncludedFile(n, context)]); + memo.set(source, includes); + } + return includes.some(([at, file]) => at > line && file === header); +} + +/** + * The line spans of the unnamed namespaces (`namespace { … }`) a C or C++ + * file writes, the outermost of nested ones: what one declares has internal + * linkage, so no other translation unit names it. Comments, preprocessor + * lines and string, character and raw string literals are skipped. + */ +function cppUnnamedNamespaceSpans(file: string, context: ResolutionContext): Array<[number, number]> { + let memo = CPP_UNNAMED_NAMESPACES.get(context); + if (!memo) CPP_UNNAMED_NAMESPACES.set(context, (memo = new Map())); + const hit = memo.get(file); + if (hit) return hit; + const spans: Array<[number, number]> = []; + const text = context.readFile(file) ?? ''; + if (/\bnamespace\s*\{/.test(text)) { + const open: Array<{ line: number; unnamed: boolean }> = []; + let line = 1; + let lineStart = true; + // The code since the last brace or `;`, without comments and literals. + let code = ''; + let i = 0; + while (i < text.length) { + const c = text[i]!; + if (c === '\n') { + line++; + lineStart = true; + code += ' '; + i++; + continue; + } + if (lineStart && c === '#') { + // A preprocessor line, continued by a trailing backslash (before a CRLF too). + let end = i; + while (end < text.length && (text[end] !== '\n' || text[end - 1] === '\\' || (text[end - 1] === '\r' && text[end - 2] === '\\'))) { + if (text[end] === '\n') line++; + end++; + } + i = end; + continue; + } + if (!/\s/.test(c)) lineStart = false; + if (c === '/' && text[i + 1] === '/') { + const end = text.indexOf('\n', i); + i = end < 0 ? text.length : end; + continue; + } + if (c === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + const stop = end < 0 ? text.length : end + 2; + for (let j = i; j < stop; j++) if (text.charCodeAt(j) === 10) line++; + i = stop; + continue; + } + if (c === '"' && /(?:^|[^\w$])(?:u8|[uUL])?R$/.test(text.slice(Math.max(0, i - 4), i))) { + // `R"delim( … )delim"`. + const paren = text.indexOf('(', i); + const end = paren < 0 ? -1 : text.indexOf(`)${text.slice(i + 1, paren)}"`, paren); + const stop = end < 0 ? text.length : end + paren - i + 1; + for (let j = i; j < stop && j < text.length; j++) if (text.charCodeAt(j) === 10) line++; + i = Math.min(stop, text.length); + continue; + } + if (c === '"' || c === "'") { + let j = i + 1; + while (j < text.length && text[j] !== c && text[j] !== '\n') j += text[j] === '\\' ? 2 : 1; + i = j + 1; + continue; + } + if (c === '{') { + open.push({ line, unnamed: /(?:^|[^\w$])namespace\s*$/.test(code) }); + code = ''; + } else if (c === '}') { + const top = open.pop(); + if (top?.unnamed && !open.some((o) => o.unnamed)) spans.push([top.line, line]); + code = ''; + } else if (c === ';') { + code = ''; + } else { + code += c; + if (code.length > 200) code = code.slice(-100); + } + i++; + } + } + memo.set(file, spans); + return spans; +} + /** * Whether a C/C++ function definition carries the `static` storage class — * read from its first source line(s), since the extractor records no storage @@ -2761,7 +2941,9 @@ function isSfcPrivate(n: Node, context: ResolutionContext): boolean { * includes it and stays visible. On a 2,109-file betaflight tree 145 * cross-file calls resolved onto a `static` in another `.c` (#1730) — * `usbd_get_descriptor` onto the `static get_device_descriptor` of - * whichever USB class file ranked first. + * whichever USB class file ranked first. So is what a source file's + * unnamed namespace declares, and a type only source files declare, with + * its members (isCppTranslationUnitLocal). * - **Kotlin, Java, C#, Swift, Scala, Dart, PHP**: `private` is class- or * file-local. An Android `editor.apply()` resolved onto an unrelated class's * `private fun apply`. @@ -2826,11 +3008,8 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex (!isCsharpTypeVisible(candidate, ref, context) || !isCsharpNestedTypeInScope(candidate, ref, context))) return false; const lang = candidate.language as string; if (lang === 'c' || lang === 'cpp') { - return ( - candidate.kind !== 'function' || - !C_SOURCE_EXT.test(candidate.filePath) || - !isStaticCFunction(candidate, context) - ); + if (candidate.kind === 'function' && C_SOURCE_EXT.test(candidate.filePath) && isStaticCFunction(candidate, context)) return false; + return !isCppTranslationUnitLocal(candidate, ref, context); } if (lang === 'go') { // By the name's first letter, not the extractor's flag: the flag is unset @@ -8851,7 +9030,7 @@ export function resolveMethodOnType( // in ANOTHER file (#314) — is unaffected: that block returns early whenever // an import FQN pins the class. if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; - const ordered = preferCallSiteFile(matches, ref.filePath); + const ordered = preferCallSiteFile(cppVisibleFirst(matches, ref, context), ref.filePath); return { original: ref, targetNodeId: ordered[0]!.id, @@ -10142,7 +10321,7 @@ function cppMethodOf(cls: Node, name: string, ref: UnresolvedRef, context: Resol const named = context.getMethodMatches?.(cls.name, name, ref.language) ?? context.getNodesByName(name).filter((n) => n.kind === 'method' && sameLanguageFamily(n.language, ref.language)); const own = named.filter((n) => n.qualifiedName === want); - if (own.length > 0) return preferCallSiteFile(own, ref.filePath)[0]!; + if (own.length > 0) return preferCallSiteFile(cppVisibleFirst(own, ref, context), ref.filePath)[0]!; seen.add(cls.qualifiedName); if (depth >= 4 || !context.getSupertypeNodes) return null; for (const decl of context.getNodesByQualifiedName(cls.qualifiedName)) { @@ -11418,6 +11597,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { AWAITED_TYPE_MEMO.delete(context); AWAITED_FILES.delete(context); C_STATIC_MEMO.delete(context); + CPP_TYPE_DECLS.delete(context); + CPP_INCLUDE_LINES.delete(context); + CPP_UNNAMED_NAMESPACES.delete(context); RUST_TRAIT_IMPL_MEMO.delete(context); RUST_USES.delete(context); RUST_CRATES.delete(context); From a6ceb0472c73e0c5c080a923c199c90267851b04 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:46:01 +0000 Subject: [PATCH 06/29] fix(cpp): a call to an overloaded method links to the overload its arguments fit (#2541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overloads of a C++ class's method were told apart by argument count and string literals only, for a call without a receiver; a call on a typed receiver always took the first one declared. rocksdb's `db->Get(ro, key, &value)` reached the five-parameter `Get(…, std::string* timestamp)`, and `Insert(ch)` on a `char ch` reached `Insert(const std::string& key, …)`. - cppOverloadFor takes the overloads the count fits best, then the one the arguments' types fit (cppArgumentTypeFit): a char, number, bool, null or zero literal; the written type of a name the calling function declares, of `&name` and of `name[i]`; or an expression's type (cppExpressionType). The same type scores, as does the same kind of type; a character, number or pointer for a string, a string or pointer for a number, and a character or number for a pointer count against. - cppMethodOf picks among a typed receiver's overloads the same way, of the class C++ found when namesake classes share its name (leveldb's two `RandomGenerator`s); a chained call `inner(…).name(…)` keeps the first, since the arguments at its position are the inner call's. - cppOverloadParameters reads an out-of-line method's defaults from its declaration in the class (protobuf's `Printer::Annotate`, whose `semantic` defaults only there). - cppOverloadFit: a string literal, raw ones too, counts against a pointer to anything but characters, a number and a character; comments are dropped from argument and parameter lists (cppWithoutComments). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-overload-argument-types.test.ts | 515 ++++++++++++++++++ src/resolution/name-matcher.ts | 254 ++++++++- 3 files changed, 758 insertions(+), 12 deletions(-) create mode 100644 __tests__/cpp-overload-argument-types.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f23fe8158..2daa541e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call written without a receiver in a gtest `TEST_F` or `TEST_P` body now links to the test fixture's method, its own or one it inherits, as C++ finds it: facebook/rocksdb's tests call `DBTestBase`'s `Put(…)`, `Flush()` and `NewIterator(…)` this way. A variable such a call initializes, like `auto iter = NewIterator(…)`, takes the type the method returns, so `iter->SeekToFirst()` reaches the iterator class. Before, those calls were guessed by name and often reached a namesake in another class, like RocksDB's `ColumnFamilyTestBase::Put` or protocolbuffers/protobuf's `EncodeDecodeTest::ExpectNoErrors`, and `iter->SeekToFirst()` reached the unrelated `MemTableRep::Iterator`. A `->` call on a value of a type from outside the project, like protobuf's `m->value()` on an `absl::StatusOr`, no longer links to a class of the project that shares the type's name, like protobuf's own `hpb::StatusOr`. Re-index C++ projects after upgrading. - In C++, a call made with `->` on an `absl::StatusOr`, an `absl::optional` or an `absl::NoDestructor`, like protocolbuffers/protobuf's `resolver->MergeFeatures(…)` on an `absl::StatusOr`, now links to the method of the class it holds, as one on a `std::unique_ptr` or `std::optional` does. A call written without a receiver in a class template now also finds a member of a base that depends on the template's parameters when the class brings it in with a using-declaration, like facebook/rocksdb's `using BasicTypedCacheHelperFns::DownCastValue;`, and so does a call on such a member. Re-index C++ projects after upgrading. - In C and C++, a call or a type name no longer links to something only another source file can see: a class, struct or alias a `.c` or `.cc` file declares for itself, its members, or anything inside that file's unnamed `namespace { … }`. In facebook/rocksdb, other test files' bare `Put(…)`, `Flush()` and `dbfull()` calls had linked to the methods of a test fixture declared in `column_family_test.cc`, and calls on an `Env*` had linked to a test-only environment class in `memenv.cc`. When a header and a source file each declare a class of the same name, like RocksDB's `SharedState`, a call now links to the header's. A source file's own declarations are still found from the file itself, from a file that `#include`s it, and from a header it includes below them. Re-index C and C++ projects after upgrading. +- In C++, a call to an overloaded method now links to the overload its arguments fit: by how many there are, and then by their types. That covers a call on a variable or member of a known class, like facebook/rocksdb's `db->Get(ro, key, &value)` and `db->Put(write_opts, key, value)`, as well as a call written without a receiver, like `Insert(ch)` on a `char ch`. Before, a call on a variable always linked to the first overload declared, so RocksDB's convenience overloads of `DB::Get`, `DB::Put` and `DB::NewIterator` and protocolbuffers/protobuf's `io::Printer::Print`, `Emit` and `Annotate` had almost no callers, and a call without a receiver picked by argument count alone. A literal, a variable the calling function declares, `&variable`, `container[i]` and the result of a call are read for their types; a parameter's default value is read from the method's declaration in its class, and comments in argument lists are skipped. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-overload-argument-types.test.ts b/__tests__/cpp-overload-argument-types.test.ts new file mode 100644 index 0000000000..52ad3b7a1f --- /dev/null +++ b/__tests__/cpp-overload-argument-types.test.ts @@ -0,0 +1,515 @@ +/** + * A C++ call among a class's overloads that take the same number of + * arguments picks the one the arguments' types fit. + * + * Overloads were told apart by argument count and string literals only, so + * a tie went to the first one written. In facebook/rocksdb's + * lru_cache_test.cc, `Insert(ch)` on a `char ch` reached `Insert(const + * std::string& key, …)` instead of `Insert(char key, …)`. What a call's + * arguments are is read where it can be: a character, number, `true`/`false` + * or `nullptr` literal, a name the calling function declares (or its + * address), or an expression whose type is known. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-overload-type-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +/** The method `qualifiedName` whose declaration starts on `line`, or the only one. */ +function method(cg: CodeGraph, qualifiedName: string, line?: number): Node { + const node = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName && (line === undefined || n.startLine === line)); + if (!node) throw new Error(`no method ${qualifiedName}${line === undefined ? '' : ` at line ${line}`}`); + return node; +} + +/** A method's `calls` edges, as ` -> : @`. */ +function callsAt(cg: CodeGraph, caller: Node): string[] { + return cg + .getOutgoingEdgesFrom([caller.id], ['calls']) + .map((e) => { + const target = cg.getNode(e.target); + return `${e.line} -> ${target?.qualifiedName}:${target?.startLine} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`; + }) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +describe('C++ overloads chosen by argument types', () => { + it("is the char overload for a char, and the string one for a string", async () => { + const cg = await indexed({ + 'cache/lru_cache_test.cc': lines( + '#include ', + 'namespace rocksdb {', + 'class LRUCacheTest {', + ' public:', + ' void Insert(const std::string& key, int priority = 0, size_t charge = 1) {}', + ' void Insert(char key, int priority = 0) {', + ' Insert(std::string(1, key), priority);', + ' }', + ' void Fill() {', + " for (char ch = 'a'; ch <= 'e'; ch++) {", + ' Insert(ch);', + ' }', + ' std::string name = "x";', + ' Insert(name);', + ' Insert(name, 1, 2);', + ' }', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(callsAt(cg, method(cg, 'rocksdb::LRUCacheTest::Fill'))).toEqual([ + '11 -> rocksdb::LRUCacheTest::Insert:6 @0.9', + '14 -> rocksdb::LRUCacheTest::Insert:5 @0.9', + '15 -> rocksdb::LRUCacheTest::Insert:5 @0.9', + ]); + expect(callsAt(cg, method(cg, 'rocksdb::LRUCacheTest::Insert', 6))).toEqual(['7 -> rocksdb::LRUCacheTest::Insert:5 @0.9']); + } finally { + cg.close(); + } + }); + + it('is the overload a number, bool, char or null literal fits', async () => { + const cg = await indexed({ + 'db/writer.cc': lines( + 'namespace rocksdb {', + 'class Slice;', + 'class Writer {', + ' public:', + ' void Write(bool flag) {}', + ' void Write(int n) {}', + ' void Write(const Slice* s) {}', + ' void Run() {', + ' Write(7);', + ' Write(false);', + ' Write(nullptr);', + " Write('c');", + ' }', + '};', + 'class Holder {', + ' public:', + ' void Set(const std::string& name) {}', + ' void Set(const Slice* slice) {}', + ' void Clear() { Set(0); }', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(callsAt(cg, method(cg, 'rocksdb::Writer::Run'))).toEqual([ + '10 -> rocksdb::Writer::Write:5 @0.9', + '11 -> rocksdb::Writer::Write:7 @0.9', + '12 -> rocksdb::Writer::Write:6 @0.9', + '9 -> rocksdb::Writer::Write:6 @0.9', + ]); + // A `0` is a null pointer as well as a number. + expect(callsAt(cg, method(cg, 'rocksdb::Holder::Clear'))).toEqual(['19 -> rocksdb::Holder::Set:18 @0.9']); + } finally { + cg.close(); + } + }); + + it("is the pointer overload for an object's address, and the string_view one for a string", async () => { + const cg = await indexed({ + 'compiler/builder.cc': lines( + '#include ', + 'namespace protobuf {', + 'struct Proto {};', + 'class Builder {', + ' public:', + ' int Build(absl::string_view schema) { return 0; }', + ' int Build(const Proto* proto) { return 1; }', + ' void Run(const Proto& proto, const std::string& text) {', + ' Build(&proto);', + ' Build(text);', + ' }', + '};', + '} // namespace protobuf', + ), + }); + try { + expect(callsAt(cg, method(cg, 'protobuf::Builder::Run'))).toEqual([ + '10 -> protobuf::Builder::Build:6 @0.9', + '9 -> protobuf::Builder::Build:7 @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('is the overload a typed receiver takes the arguments to', async () => { + const cg = await indexed({ + 'cache/lru_cache.cc': lines( + '#include ', + 'namespace rocksdb {', + 'class LRUCacheShard {', + ' public:', + ' void Insert(const std::string& key, int priority = 0) {}', + ' void Insert(char key, int priority = 0) {}', + '};', + 'void Fill(LRUCacheShard* shard) {', + " char ch = 'a';", + ' shard->Insert(ch);', + '}', + '} // namespace rocksdb', + ), + }); + try { + const fill = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'rocksdb::Fill')!; + expect(callsAt(cg, fill)).toEqual(['10 -> rocksdb::LRUCacheShard::Insert:6 @0.9']); + } finally { + cg.close(); + } + }); + + it("is an overload of the receiver's own class, not of a namesake another source file declares", async () => { + // leveldb's db_bench.cc and db_bench_sqlite3.cc each declare a RandomGenerator. + const cg = await indexed({ + 'benchmarks/db_bench.cc': lines( + 'class RandomGenerator {', + ' public:', + ' const char* Generate(size_t len) { return nullptr; }', + '};', + 'class Benchmark {', + ' int value_size_;', + ' void Write() {', + ' RandomGenerator gen;', + ' gen.Generate(value_size_);', + ' }', + '};', + ), + 'benchmarks/db_bench_sqlite3.cc': lines( + 'class RandomGenerator {', + ' public:', + ' const char* Generate(int len) { return nullptr; }', + '};', + ), + }); + try { + expect(callsAt(cg, method(cg, 'Benchmark::Write')).map((c) => c.replace(/ @.*$/, ''))).toEqual(['9 -> RandomGenerator::Generate:3']); + const generate = cg.getOutgoingEdgesFrom([method(cg, 'Benchmark::Write').id], ['calls']).map((e) => cg.getNode(e.target)?.filePath); + expect(generate).toEqual(['benchmarks/db_bench.cc']); + } finally { + cg.close(); + } + }); + + it("keeps a chained call's first overload: the arguments where it starts are the inner call's", async () => { + const cg = await indexed({ + 'gmock/gmock.h': lines( + 'namespace testing {', + 'class Sequence {};', + 'namespace internal {', + 'template ', + 'class TypedExpectation {', + ' public:', + ' TypedExpectation& InSequence(const Sequence& s) { return *this; }', + ' TypedExpectation& InSequence(const Sequence& s1, const Sequence& s2) { return *this; }', + ' TypedExpectation& InSequence(const Sequence& s1, const Sequence& s2,', + ' const Sequence& s3) {', + ' return InSequence(s1, s2).InSequence(s3);', + ' }', + '};', + '} // namespace internal', + '} // namespace testing', + ), + }); + try { + expect(callsAt(cg, method(cg, 'testing::internal::TypedExpectation::InSequence', 9)).map((c) => c.replace(/ @.*$/, ''))).toEqual([ + '11 -> testing::internal::TypedExpectation::InSequence:7', + '11 -> testing::internal::TypedExpectation::InSequence:8', + ]); + } finally { + cg.close(); + } + }); + + it("is not an overload whose parameter a string literal can't be passed to", async () => { + // rocksdb's `db_->Get(ro, db_->DefaultColumnFamily(), "empty_key", &result)`. + const cg = await indexed({ + 'include/rocksdb/db.h': lines( + '#include ', + 'namespace rocksdb {', + 'struct ReadOptions {};', + 'class Slice { public: Slice(const char* s) {} };', + 'struct PinnableSlice {};', + 'struct ColumnFamilyHandle {};', + 'class DB {', + ' public:', + ' virtual int Get(const ReadOptions& options, const Slice& key, std::string* value, std::string* timestamp) = 0;', + ' virtual int Get(const ReadOptions& options, ColumnFamilyHandle* column_family, const Slice& key, PinnableSlice* value) = 0;', + '};', + 'int Read(DB* db, ColumnFamilyHandle* cf, PinnableSlice* result) {', + ' ReadOptions ro;', + ' return db->Get(ro, cf, "empty_key", result);', + '}', + '} // namespace rocksdb', + ), + }); + try { + const read = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'rocksdb::Read')!; + expect(callsAt(cg, read)).toEqual(['14 -> rocksdb::DB::Get:10 @0.9']); + } finally { + cg.close(); + } + }); + + it("reads a method's defaults from its declaration in the class, and a raw string as a string literal", async () => { + // protobuf's io::Printer, whose overloads are defined below the class or in printer.cc. + const cg = await indexed({ + 'io/printer.h': lines( + 'namespace io {', + 'struct Sub {};', + 'class EnumDescriptor {};', + 'class Printer {', + ' public:', + ' void Annotate(absl::string_view varname, absl::string_view file_name, int semantic = 0) {}', + ' template ', + ' void Annotate(absl::string_view begin_varname, absl::string_view end_varname,', + ' const Desc* descriptor, int semantic = 0);', + ' void Emit(absl::string_view format, SourceLocation loc = SourceLocation::current());', + ' void Emit(absl::Span vars, absl::string_view format,', + ' SourceLocation loc = SourceLocation::current());', + '};', + 'template ', + 'void Printer::Annotate(absl::string_view begin_varname, absl::string_view end_varname,', + ' const Desc* descriptor, int semantic) {}', + 'inline void Printer::Emit(absl::string_view format, SourceLocation loc) {}', + '} // namespace io', + ), + 'io/printer.cc': lines( + '#include "io/printer.h"', + 'namespace io {', + 'void Printer::Emit(absl::Span vars, absl::string_view format, SourceLocation loc) {}', + '} // namespace io', + ), + 'compiler/enum.cc': lines( + '#include "io/printer.h"', + 'namespace io {', + 'void Generate(Printer* printer, const EnumDescriptor* descriptor) {', + ' printer->Annotate("{", "}", descriptor);', + ' printer->Emit({Sub()}, R"cc(', + ' $name$_ { visibility }', + ' )cc");', + '}', + '} // namespace io', + ), + }); + try { + const generate = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'io::Generate')!; + expect(callsAt(cg, generate).map((c) => c.replace(/ @.*$/, ''))).toEqual([ + '4 -> io::Printer::Annotate:15', + '5 -> io::Printer::Emit:3', + ]); + } finally { + cg.close(); + } + }); + + it("reads an element of a container a subscript names", async () => { + // rocksdb's TransactionBaseImpl: `TryLock(column_family[i], keys[i], …)` over a + // `std::vector`, in the .cc that defines the SliceParts overload. + const cg = await indexed({ + 'utilities/transactions/transaction_base.h': lines( + 'namespace rocksdb {', + 'class Slice {};', + 'class SliceParts {};', + 'class ColumnFamilyHandle {};', + 'class TransactionBaseImpl {', + ' public:', + ' int TryLock(ColumnFamilyHandle* column_family, const Slice& key, bool read_only, bool exclusive) { return 0; }', + ' int TryLock(ColumnFamilyHandle* column_family, const SliceParts& key, bool read_only, bool exclusive);', + ' void MultiGetForUpdate(const std::vector& column_family, const std::vector& keys);', + '};', + '} // namespace rocksdb', + ), + 'utilities/transactions/transaction_base.cc': lines( + '#include "utilities/transactions/transaction_base.h"', + 'namespace rocksdb {', + 'int TransactionBaseImpl::TryLock(ColumnFamilyHandle* column_family, const SliceParts& key, bool read_only, bool exclusive) {', + ' return 1;', + '}', + 'void TransactionBaseImpl::MultiGetForUpdate(const std::vector& column_family,', + ' const std::vector& keys) {', + ' for (size_t i = 0; i < keys.size(); i++) {', + ' TryLock(column_family[i], keys[i], true, true);', + ' }', + '}', + '} // namespace rocksdb', + ), + }); + try { + const multiGet = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'rocksdb::TransactionBaseImpl::MultiGetForUpdate')!; + const tryLock = cg.getOutgoingEdgesFrom([multiGet.id], ['calls']).map((e) => cg.getNode(e.target)).filter((n) => n?.name === 'TryLock'); + expect(tryLock.map((n) => `${n!.filePath}:${n!.startLine}`)).toEqual(['utilities/transactions/transaction_base.h:7']); + } finally { + cg.close(); + } + }); + + it('reads arguments and parameters past their comments', async () => { + const cg = await indexed({ + 'cache/cache.cc': lines( + '#include ', + 'namespace rocksdb {', + 'class Cache {', + ' public:', + ' int Insert(const std::string& key, const void* value, int hash) { return 1; }', + ' int Insert(const std::string& key, int hash, const void* value) { return 0; }', + ' void Fill(const std::string& key) {', + ' Insert(key, 0 /*hash*/, nullptr /*value*/);', + ' }', + '};', + 'class TextGenerator {', + ' public:', + ' void PrintEnum(int32_t /*val*/, const std::string& name) {}', + ' void PrintEnum(int32_t val, absl::string_view name) {', + ' PrintEnum(val, std::string(name));', + ' }', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(callsAt(cg, method(cg, 'rocksdb::Cache::Fill'))).toEqual(['8 -> rocksdb::Cache::Insert:6 @0.9']); + // (protobuf's text_format.cc: a parameter named in a comment is still an `int32_t`.) + expect(callsAt(cg, method(cg, 'rocksdb::TextGenerator::PrintEnum', 14))).toEqual(['15 -> rocksdb::TextGenerator::PrintEnum:13 @0.9']); + } finally { + cg.close(); + } + }); + + it('scores each kind of argument against each kind of parameter, whatever the order they are declared in', async () => { + const cg = await indexed({ + 'util/scorer.h': lines( + '#include ', + 'namespace rocksdb {', + 'class Slice { public: Slice(const char* s) {} };', + 'class Foo {};', + "char MakeChar() { return 'a'; }", + 'class Scorer {', + ' public:', + ' void Num(int32_t n) {}', + ' void Num(int64_t n) {}', + ' void Text(const Slice& s) {}', + ' void Text(absl::string_view s) {}', + ' void Value(const std::string& s) {}', + ' void Value(Foo f) {}', + ' void Count(int n) {}', + ' void Count(Foo f) {}', + ' void Ptr(const Slice* p) {}', + ' void Ptr(Foo f) {}', + ' void Lit(int n) {}', + ' void Lit(const char* s) {}', + ' void Key(const std::string& k) {}', + ' void Key(char k) {}', + ' void Run(int64_t wide, const std::string& name, int n) {', + ' Num(wide);', + ' Text(name);', + ' Value(n);', + ' Count(name);', + ' Ptr(n);', + ' Lit(R"(raw)");', + ' Key(MakeChar());', + ' }', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(callsAt(cg, method(cg, 'rocksdb::Scorer::Run')).map((c) => c.replace(/ @.*$/, ''))).toEqual([ + // The type itself over another of its kind. + '23 -> rocksdb::Scorer::Num:9', + // A string for a string type, over a class of another name. + '24 -> rocksdb::Scorer::Text:11', + // A number for no string, a string for no number, a number for no pointer. + '25 -> rocksdb::Scorer::Value:13', + '26 -> rocksdb::Scorer::Count:15', + '27 -> rocksdb::Scorer::Ptr:17', + // A raw string is a string literal. + '28 -> rocksdb::Scorer::Lit:19', + // What a call returns. + '29 -> rocksdb::MakeChar:5', + '29 -> rocksdb::Scorer::Key:21', + ]); + } finally { + cg.close(); + } + }); + + it("reads no type from another function's local of the name", async () => { + const cg = await indexed({ + 'cache/lru_cache_test.cc': lines( + '#include ', + 'namespace rocksdb {', + 'class LRUCacheTest {', + ' public:', + ' void Insert(const std::string& key) {}', + ' void Insert(char key) {}', + '};', + "void Earlier() { char ch = 'x'; }", + 'void Later(LRUCacheTest* t) {', + ' t->Insert(ch);', + '}', + '} // namespace rocksdb', + ), + }); + try { + const later = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'rocksdb::Later')!; + expect(callsAt(cg, later).map((c) => c.replace(/ @.*$/, ''))).toEqual(['10 -> rocksdb::LRUCacheTest::Insert:5']); + } finally { + cg.close(); + } + }); + + it('keeps the first overload when the arguments say nothing', async () => { + const cg = await indexed({ + 'cache/lru_cache_test.cc': lines( + '#include ', + 'namespace rocksdb {', + 'class LRUCacheTest {', + ' public:', + ' void Insert(const std::string& key) {}', + ' void Insert(char key) {}', + ' void Fill() {', + " char ch = 'a';", + ' {', + ' auto ch = MakeKey();', + ' Insert(ch);', + ' }', + ' Insert(MakeKey());', + ' }', + '};', + '} // namespace rocksdb', + ), + }); + try { + // The inner `ch` is MakeKey()'s, which isn't known: not the outer `char ch`. + expect(callsAt(cg, method(cg, 'rocksdb::LRUCacheTest::Fill')).filter((c) => c.includes('Insert'))).toEqual([ + '11 -> rocksdb::LRUCacheTest::Insert:5 @0.9', + '13 -> rocksdb::LRUCacheTest::Insert:5 @0.9', + ]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 49e893e3cc..ed9ef9643b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -10313,15 +10313,28 @@ function cppBasesKnown(cls: Node, context: ResolutionContext, depth = 0, seen = } /** - * C++ class `cls`'s method `name`: its own, else the nearest one of a class + * C++ class `cls`'s method `name`: its own — of its overloads, the one the + * call's arguments fit (cppOverloadFor) — else the nearest one of a class * it derives from, through the base edges every declaration of `cls` has. + * When other classes share `cls`'s qualified name, its own are the ones + * defined where it is: leveldb's db_bench.cc and db_bench_sqlite3.cc each + * declare a `RandomGenerator`, with a `Generate` that takes a `size_t` and + * an `int`. (A chained call `inner(…).name(…)` keeps the first: the + * arguments at its position are the inner call's.) */ function cppMethodOf(cls: Node, name: string, ref: UnresolvedRef, context: ResolutionContext, depth = 0, seen = new Set()): Node | null { const want = `${cls.qualifiedName}::${name}`; const named = context.getMethodMatches?.(cls.name, name, ref.language) ?? context.getNodesByName(name).filter((n) => n.kind === 'method' && sameLanguageFamily(n.language, ref.language)); const own = named.filter((n) => n.qualifiedName === want); - if (own.length > 0) return preferCallSiteFile(cppVisibleFirst(own, ref, context), ref.filePath)[0]!; + if (own.length > 0) { + const ordered = preferCallSiteFile(cppVisibleFirst(own, ref, context), ref.filePath); + if (ref.referenceName.includes('().')) return ordered[0]!; + const defined = cppClassDecls(cls.qualifiedName, context).length > 1 + ? ordered.filter((n) => n.filePath === cls.filePath || cppIncluders(cls.filePath, context).has(n.filePath)) + : ordered; + return cppOverloadFor(defined.length > 0 ? defined : ordered, name, ref, context); + } seen.add(cls.qualifiedName); if (depth >= 4 || !context.getSupertypeNodes) return null; for (const decl of context.getNodesByQualifiedName(cls.qualifiedName)) { @@ -10582,15 +10595,28 @@ function cppScopeMethod(scope: CppClassScope, name: string, receiver: 'bare' | ' return null; } -/** Of one class's overloads of `name` (in preference order), the one the call's arguments fit best. */ +/** + * Of one class's overloads of `name` (in preference order), the one the + * call's arguments fit best: by their count and string literals + * (cppOverloadFit), then, among overloads those fit alike, by the types the + * arguments have where the call reads them (cppArgumentTypeFit). rocksdb's + * `Insert(ch)` on a `char ch` is `Insert(char key, …)`, not `Insert(const + * std::string& key, …)`. + */ function cppOverloadFor(overloads: Node[], name: string, ref: UnresolvedRef, context: ResolutionContext): Node { if (overloads.length === 1) return overloads[0]!; const args = cppCallArguments(ref, name, context); if (!args) return overloads[0]!; - let best = overloads[0]!; - let bestFit = cppOverloadFit(best, name, args, context); - for (const n of overloads.slice(1)) { - const fit = cppOverloadFit(n, name, args, context); + const fits = overloads.map((n) => cppOverloadFit(n, name, args, context)); + const top = Math.max(...fits); + const tied = overloads.filter((_, i) => fits[i] === top); + if (tied.length === 1 || args.length === 0) return tied[0]!; + const types = args.map((arg) => cppArgumentType(arg, ref, context)); + if (types.every((t) => t === null)) return tied[0]!; + let best = tied[0]!; + let bestFit = cppArgumentTypeFit(best, name, types, context); + for (const n of tied.slice(1)) { + const fit = cppArgumentTypeFit(n, name, types, context); if (fit > bestFit) { best = n; bestFit = fit; @@ -10599,6 +10625,130 @@ function cppOverloadFor(overloads: Node[], name: string, ref: UnresolvedRef, con return best; } +/** What a C++ call's argument is: a literal's kind, or the type a name or expression has as written. */ +type CppArgumentType = { kind: 'char' | 'number' | 'zero' | 'bool' | 'null' } | { kind: 'type'; text: string }; + +/** + * What the argument `arg` of the call at `ref` is, where that can be read: a + * character, number, `true`/`false` or `nullptr` literal, or the written + * type of a name the calling function declares, of `&name`, of `name[i]` + * (rocksdb's `TryLock(column_family[i], keys[i], …)` on a + * `std::vector`), or of an expression (cppExpressionType). Null + * otherwise. (String literals are cppOverloadFit's.) + */ +function cppArgumentType(arg: string, ref: UnresolvedRef, context: ResolutionContext): CppArgumentType | null { + const text = arg.trim(); + if (/^(?:u8|u|U|L)?'(?:[^'\\]|\\.)+'$/.test(text)) return { kind: 'char' }; + if (text === 'true' || text === 'false') return { kind: 'bool' }; + if (text === 'nullptr' || text === 'NULL') return { kind: 'null' }; + if (/^0+[uUlL]*$/.test(text)) return { kind: 'zero' }; + if (/^[-+]?(?:0[xX][\da-fA-F']+|\d[\d']*(?:\.\d*)?(?:[eE][-+]?\d+)?|\.\d+(?:[eE][-+]?\d+)?)[uUlLfFzZ]*$/.test(text)) return { kind: 'number' }; + const address = /^&\s*([A-Za-z_]\w*)$/.exec(text); + const name = address?.[1] ?? (/^[A-Za-z_]\w*$/.test(text) ? text : null); + if (name) { + const decl: CppReceiverDeclaration = {}; + inferCppReceiverType(name, ref, context, 0, decl); + const written = decl.declared?.text ?? decl.raw; + if (!written || !decl.callers || decl.shadowed) return null; + return { kind: 'type', text: address ? `${written}*` : written }; + } + // `keys[i]`: an element of the container, or what the pointer points at, a name the calling function declares is. + const subscript = /^([A-Za-z_]\w*)\s*\[[^[\]]*\]$/.exec(text); + if (subscript) { + const container = cppExpressionType(subscript[1]!, ref, context, 0); + const followed = container && cppFollowWrittenAliases(container, ref, context); + const element = followed && (isCppPointerType(followed.text) ? cppPointee(followed) : cppRangeElementOf(followed, ref, context)); + return element ? { kind: 'type', text: element.text } : null; + } + const typed = /^(?:u8|u|U|L)?R?["']/.test(text) ? null : cppExpressionType(text, ref, context, 0); + return typed ? { kind: 'type', text: typed.text } : null; +} + +/** What a C++ type is, for an argument to fit a parameter of it. */ +type CppTypeCategory = 'string' | 'cstring' | 'char' | 'number' | 'bool' | 'pointer' | 'class'; + +const CPP_CHAR_TYPES: ReadonlySet = new Set(['char', 'wchar_t', 'char8_t', 'char16_t', 'char32_t']); +const CPP_NUMBER_TYPES: ReadonlySet = new Set([ + 'int', 'unsigned', 'signed', 'short', 'long', 'float', 'double', 'size_t', 'ssize_t', 'ptrdiff_t', + 'int8_t', 'int16_t', 'int32_t', 'int64_t', 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t', 'intptr_t', 'uintptr_t', +]); +const CPP_STRING_TYPES: ReadonlySet = new Set(['string', 'wstring', 'string_view', 'wstring_view', 'basic_string', 'basic_string_view']); + +/** The category of the written C++ type `type`: a pointer's, a string's, a character's, a number's, a class's. */ +function cppTypeCategory(type: string): CppTypeCategory { + const flat = stripCppTemplateArguments(type).replace(/\b(?:const|volatile|struct|class|typename|enum)\b/g, ' ').replace(/&+/g, ' ').trim(); + const words = flat.replace(/\*/g, ' ').trim().split(/\s+/); + const last = cppLastSegment(words[words.length - 1] ?? ''); + if (flat.includes('*')) return CPP_CHAR_TYPES.has(last) && (flat.match(/\*/g) ?? []).length === 1 ? 'cstring' : 'pointer'; + if (last === 'bool') return 'bool'; + if (CPP_CHAR_TYPES.has(last) && words.every((w) => CPP_CHAR_TYPES.has(w) || w === 'signed' || w === 'unsigned')) return 'char'; + if (CPP_NUMBER_TYPES.has(last) || words.every((w) => CPP_NUMBER_TYPES.has(w))) return 'number'; + return CPP_STRING_TYPES.has(last) ? 'string' : 'class'; +} + +/** + * How well the read argument types `types` fit the parameters of the + * overload `n`: the same type, or a character to a character, scores; a + * character, number or pointer to a string, or a string or pointer to a + * number, counts against. Classes of other names score nothing: a conversion + * may join them. + */ +function cppArgumentTypeFit(n: Node, name: string, types: Array, context: ResolutionContext): number { + const list = cppOverloadParameters(n, name, context); + if (list === null) return 0; + const params = splitCppTopLevel(cppWithoutComments(list)).filter((p) => p !== 'void'); + let score = 0; + for (let i = 0; i < types.length && i < params.length; i++) { + const arg = types[i]; + const param = cppParameterType(params[i]!); + if (!arg || param.includes('...')) continue; + const category = cppTypeCategory(param); + score += arg.kind === 'type' ? cppWrittenTypeFit(arg.text, param, category) : CPP_LITERAL_FIT[arg.kind][category]; + } + return score; +} + +/** How a literal fits a parameter of each category. */ +const CPP_LITERAL_FIT: Record<'char' | 'number' | 'zero' | 'bool' | 'null', Record> = { + char: { char: 3, number: 1, bool: 0, string: -3, cstring: -3, pointer: -3, class: 0 }, + number: { number: 2, char: 1, bool: 0, string: -3, cstring: -3, pointer: -3, class: 0 }, + zero: { number: 2, char: 1, bool: 0, pointer: 1, cstring: 1, string: -3, class: 0 }, + bool: { bool: 3, number: 1, char: 0, string: -3, cstring: -3, pointer: -3, class: 0 }, + null: { pointer: 3, cstring: 3, class: 1, string: -3, char: -3, number: -3, bool: -1 }, +}; + +/** How an argument written as type `arg` fits a parameter of type `param` (of category `category`). */ +function cppWrittenTypeFit(arg: string, param: string, category: CppTypeCategory): number { + const argCategory = cppTypeCategory(arg); + const pointer = (c: CppTypeCategory): boolean => c === 'pointer' || c === 'cstring'; + if (normalizeCppTypeName(arg) === normalizeCppTypeName(param) && pointer(argCategory) === pointer(category)) return 3; + if (argCategory === category && argCategory !== 'class') return 2; + const textual = (c: CppTypeCategory): boolean => c === 'string' || c === 'cstring'; + if ((argCategory === 'char' || argCategory === 'number' || argCategory === 'pointer') && category === 'string') return -3; + if ((textual(argCategory) || argCategory === 'pointer') && (category === 'char' || category === 'number')) return -3; + if ((argCategory === 'char' || argCategory === 'number') && (category === 'pointer' || category === 'cstring')) return -3; + return 0; +} + +/** A C++ parameter declaration's type: without its default argument and its name. */ +function cppParameterType(param: string): string { + let text = param; + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (c === '<' || c === '(' || c === '[') depth++; + else if (c === '>' || c === ')' || c === ']') depth--; + else if (c === '=' && depth === 0) { + text = text.slice(0, i); + break; + } + } + text = text.trim(); + // The name, when a type comes before it. + const named = /^(.*[\w>*&\s])\b[A-Za-z_]\w*\s*(?:\[[^\]]*\])?$/.exec(text); + return named && /[A-Za-z_]/.test(named[1]!) && !/^(?:const|volatile|unsigned|signed|struct|class|enum)\s*$/.test(named[1]!.trim()) ? named[1]!.trim() : text; +} + const CPP_MEMBER_MACRO_ARGS = new WeakMap>(); /** @@ -11631,6 +11781,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CPP_BASE_SPECS.delete(context); CPP_USING_MEMBERS.delete(context); CPP_MEMBER_MACRO_ARGS.delete(context); + CPP_OVERLOAD_PARAMETERS.delete(context); CPP_LOOP_ELEMENTS.delete(context); CPP_AUTO_LOCALS.delete(context); CPP_SCOPE_NAMES.delete(context); @@ -15921,17 +16072,89 @@ function cppParenListAfter(file: string, line: number, column: number, name: str return null; } +/** C++ text with each comment blanked to a space; string and character literals are kept. */ +function cppWithoutComments(text: string): string { + if (!text.includes('/')) return text; + let out = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (c === '"' || c === "'") { + let j = i + 1; + while (j < text.length && text[j] !== c && text[j] !== '\n') j += text[j] === '\\' ? 2 : 1; + out += text.slice(i, j + 1); + i = j; + } else if (c === '/' && text[i + 1] === '/') { + const end = text.indexOf('\n', i); + i = end < 0 ? text.length : end - 1; + out += ' '; + } else if (c === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + i = end < 0 ? text.length : end + 1; + out += ' '; + } else { + out += c; + } + } + return out; +} + function cppCallArguments(ref: UnresolvedRef, name: string, context: ResolutionContext): string[] | null { const list = cppParenListAfter(ref.filePath, ref.line, Math.max(0, ref.column), name, context); - return list === null ? null : splitCppTopLevel(list); + return list === null ? null : splitCppTopLevel(cppWithoutComments(list)); +} + +/** Per context: a C++ method's id → the parameter list its defaults are read from (cppOverloadParameters). */ +const CPP_OVERLOAD_PARAMETERS = new WeakMap>(); + +/** + * The parameter list of the C++ overload `n`, its `( … )`, as it gives the + * parameters' defaults: for a method defined outside its class, the list + * the method's declaration in the class writes. protobuf defines `template + * void Printer::Annotate(…, const Desc* descriptor, + * absl::optional semantic)` below the class, whose declaration + * gave `semantic` its `= absl::nullopt`. Its own list when the class + * declares the method with the same parameter types and no default, or + * not at all. + */ +function cppOverloadParameters(n: Node, name: string, context: ResolutionContext): string | null { + const own = cppParenListAfter(n.filePath, n.startLine, 0, name, context); + if (own === null || n.kind !== 'method') return own; + let memo = CPP_OVERLOAD_PARAMETERS.get(context); + if (!memo) CPP_OVERLOAD_PARAMETERS.set(context, (memo = new Map())); + const hit = memo.get(n.id); + if (hit !== undefined) return hit; + let found = own; + const classes = cppClassDecls(cppParentScope(n.qualifiedName), context); + if (!classes.some((c) => c.filePath === n.filePath && rangeWithin(n, c))) { + const types = (list: string): string[] => splitCppTopLevel(cppWithoutComments(list)) + .filter((p) => p !== 'void') + .map((p) => normalizeCppTypeName(cppParameterType(p)) ?? p); + const want = types(own); + const word = new RegExp(`\\b${name.replace(/[~]/g, '\\$&')}\\s*\\(`); + search: for (const cls of classes) { + const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? []; + for (let line = cls.startLine; line <= Math.min(lines.length, cls.endLine ?? cls.startLine); line++) { + if (!word.test(lines[line - 1]!)) continue; + const list = cppParenListAfter(cls.filePath, line, 0, name, context); + if (list === null || !list.includes('=')) continue; + const have = types(list); + if (have.length === want.length && have.every((t, i) => t === want[i])) { + found = list; + break search; + } + } + } + } + memo.set(n.id, found); + return found; } /** How well a call's arguments fit an overload's parameters: arity, and a string literal's first slot. */ function cppOverloadFit(n: Node, name: string, args: string[], context: ResolutionContext): number { if (n.kind !== 'function' && n.kind !== 'method') return -1; - const list = cppParenListAfter(n.filePath, n.startLine, 0, name, context); + const list = cppOverloadParameters(n, name, context); if (list === null) return 0; - const params = splitCppTopLevel(list).filter((p) => p !== 'void'); + const params = splitCppTopLevel(cppWithoutComments(list)).filter((p) => p !== 'void'); // A pack is `T&&... args`, not the `...` inside `format_string`. const isPack = (p: string): boolean => { let flat = p; @@ -15945,14 +16168,21 @@ function cppOverloadFit(n: Node, name: string, args: string[], context: Resoluti // Each string literal against its parameter: a string type by name over a // template parameter that might be one (`const S&`), and a narrow literal // never a wide parameter (`fmt::join(v, ", ")` is not xchar.h's `wstring_view`). + // A literal, raw ones too (protobuf's `p->Emit({…}, R"cc(…)cc")`), is a + // `const char*`: no pointer to anything else (rocksdb's `std::string* + // value`), number or character takes it, which counts for more than a + // class of another name, one that may convert from it (`const Slice& key`) + // or may not (fmt's `text_style`). for (let i = 0; i < args.length && i < params.length; i++) { const arg = args[i]!; const param = params[i]!; if (isPack(param)) break; - if (!/^(?:u8|u|U|L)?"|^FMT_STRING\s*\(/.test(arg)) continue; - const wideArg = /^L"/.test(arg); + if (!/^(?:u8|u|U|L)?R?"|^FMT_STRING\s*\(/.test(arg)) continue; + const wideArg = /^LR?"/.test(arg); const wideParam = /\bw(?:string|char_t|format|string_view)|wchar_t/.test(param); + const category = cppTypeCategory(cppParameterType(param)); if (wideArg !== wideParam && /string|char|Char|format/.test(param)) score -= 2; + else if (category === 'pointer' || category === 'number' || category === 'bool' || category === 'char') score -= 3; else score += /string|char|Char|\bstr\b/.test(param) ? 3 : /^(?:const\s+)?[A-Z]\w{0,2}\s*[&*]{0,2}\s*\w*$/.test(param) ? 1 : -2; } return score; From 34dd3d2fb4e15199d313f36450b095acf32dcb14 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 13 Oct 2026 17:47:02 +0000 Subject: [PATCH 07/29] fix(cpp): a nested class defined out of line is indexed under its name, and a construction makes the class C++ finds (#2542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class, struct or union whose declaration writes its name in a scope — leveldb's `struct SkipList::Node { … }`, rocksdb's `struct BlockBasedTable::Rep { … }` — was indexed as ``, so its members belonged to no class anyone names and a call on a `Node*` or `Rep*` was guessed by the method's name. Such a class now takes its own name, qualified under the written scope the way an out-of-line method is, and its members are qualified under it, in the TS extractor and the kernel alike. A specialization (`struct fmt::formatter`) stays unnamed: named after its template, it stood in for the template and every other specialization. Constructions are recorded by the type's last name, so with `DBImpl::Writer` named, leveldb's `new log::Writer(…)` in `DBImpl` methods went to it. A C++ construction now makes the class C++ lookup finds for the type as written at the construction (matchCppConstruction): read with its template arguments (`new Outer::Inner(…)` makes an `Outer::Inner`), at 0.9, or 0.7 when it is only the class of that name the file can see; an enum lookup finds; through an alias the calling function declares, the class it names; and nothing for a template parameter, a member of a type from outside the project or a local alias of one. Of several classes or aliases the file declares under one qualified name (the local classes of separate gtest TEST_F bodies, the members of unnamed specializations), the one declared around the construction. A `<` or `>` in parentheses inside template arguments is now read as a comparison. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-written-scope-class-names.test.ts | 498 ++++++++++++++++++ __tests__/kernel-ccpp-parity.test.ts | 37 ++ codegraph-kernel/src/ccpp/mod.rs | 83 ++- src/extraction/languages/c-cpp.ts | 28 +- src/extraction/tree-sitter-types.ts | 8 + src/extraction/tree-sitter.ts | 18 + src/resolution/cpp-type-aliases.ts | 17 +- src/resolution/name-matcher.ts | 136 ++++- 9 files changed, 814 insertions(+), 12 deletions(-) create mode 100644 __tests__/cpp-written-scope-class-names.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2daa541e1a..7c01e09cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call made with `->` on an `absl::StatusOr`, an `absl::optional` or an `absl::NoDestructor`, like protocolbuffers/protobuf's `resolver->MergeFeatures(…)` on an `absl::StatusOr`, now links to the method of the class it holds, as one on a `std::unique_ptr` or `std::optional` does. A call written without a receiver in a class template now also finds a member of a base that depends on the template's parameters when the class brings it in with a using-declaration, like facebook/rocksdb's `using BasicTypedCacheHelperFns::DownCastValue;`, and so does a call on such a member. Re-index C++ projects after upgrading. - In C and C++, a call or a type name no longer links to something only another source file can see: a class, struct or alias a `.c` or `.cc` file declares for itself, its members, or anything inside that file's unnamed `namespace { … }`. In facebook/rocksdb, other test files' bare `Put(…)`, `Flush()` and `dbfull()` calls had linked to the methods of a test fixture declared in `column_family_test.cc`, and calls on an `Env*` had linked to a test-only environment class in `memenv.cc`. When a header and a source file each declare a class of the same name, like RocksDB's `SharedState`, a call now links to the header's. A source file's own declarations are still found from the file itself, from a file that `#include`s it, and from a header it includes below them. Re-index C and C++ projects after upgrading. - In C++, a call to an overloaded method now links to the overload its arguments fit: by how many there are, and then by their types. That covers a call on a variable or member of a known class, like facebook/rocksdb's `db->Get(ro, key, &value)` and `db->Put(write_opts, key, value)`, as well as a call written without a receiver, like `Insert(ch)` on a `char ch`. Before, a call on a variable always linked to the first overload declared, so RocksDB's convenience overloads of `DB::Get`, `DB::Put` and `DB::NewIterator` and protocolbuffers/protobuf's `io::Printer::Print`, `Emit` and `Annotate` had almost no callers, and a call without a receiver picked by argument count alone. A literal, a variable the calling function declares, `&variable`, `container[i]` and the result of a call are read for their types; a parameter's default value is read from the method's declaration in its class, and comments in argument lists are skipped. Re-index C++ projects after upgrading. +- In C++, a nested class defined outside the class it belongs to, like google/leveldb's `struct SkipList::Node { … }` or facebook/rocksdb's `struct BlockBasedTable::Rep { … }`, is now indexed under its own name, `SkipList::Node`, with its methods and members. Before, it was indexed as an unnamed class, so it was missing from search and `codegraph_explore`, its methods belonged to no class you could name, and a call like `x->Next(level)` on a `Node*` was matched by the method's name alone. Creating an object, as in `new log::Writer(…)` or `Writer w(…)`, now links to the class C++ itself finds for the type as written there: leveldb's `new log::Writer(…)` in `DBImpl` methods no longer links to `DBImpl`'s own `Writer`, RocksDB's tests' `Random rnd(301)` links to RocksDB's `Random` instead of googletest's, an object created through an alias links to the class the alias names, and one links to its class rather than to the class's constructor. An object of a template parameter's type, or of a type the function names with its own `using`, like protocolbuffers/protobuf's `using Map = std::map<…>`, no longer links to a project class that only shares the name. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-written-scope-class-names.test.ts b/__tests__/cpp-written-scope-class-names.test.ts new file mode 100644 index 0000000000..b0125308e9 --- /dev/null +++ b/__tests__/cpp-written-scope-class-names.test.ts @@ -0,0 +1,498 @@ +/** + * A C++ class, struct or union whose declaration writes its name with a + * scope — a nested class defined outside the class it is nested in — is + * indexed by its own name, qualified under that scope. + * + * google/leveldb defines `template struct + * SkipList::Node { … }` below the class, and facebook/rocksdb + * `struct BlockBasedTable::Rep { … }` in a source file. Each was indexed as a + * class named ``, so its members belonged to no class anyone + * names, and a call on a `Node*` or `Rep*` couldn't reach them: leveldb's + * `x->Next(level)` was guessed by the method's name. Such a class is now + * `Node`, qualified `leveldb::SkipList::Node` the way an out-of-line method + * `SkipList::Node::Next` already was, and its members are + * qualified under it. A specialization (`struct fmt::formatter`) + * keeps out of its template's name. + * + * A construction is recorded by its type's last name, so with + * `DBImpl::Writer` named, leveldb's `new log::Writer(…)` in a `DBImpl` method + * went to it. A construction now makes the class C++ lookup finds for the + * type as written there. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-scoped-class-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** Every class, struct and union node, as `kind qualifiedName`, with the members it contains. */ +function classes(cg: CodeGraph): Record { + const out: Record = {}; + for (const n of [...cg.getNodesByKind('class'), ...cg.getNodesByKind('struct'), ...cg.getNodesByKind('union')]) { + const members = cg.getOutgoingEdgesFrom([n.id], ['contains']) + .map((e) => cg.getNode(e.target)) + .filter((m): m is Node => m !== null && (m.kind === 'method' || m.kind === 'field' || m.kind === 'property')) + .map((m) => m.qualifiedName) + .sort(); + out[`${n.kind} ${n.qualifiedName}`] = members; + } + return out; +} + +const SKIPLIST = lines( + 'namespace leveldb {', + 'template ', + 'class SkipList {', + ' public:', + ' void Insert(const Key& key) {', + ' Node* x = head_;', + ' x->SetNext(0, x->Next(0));', + ' }', + ' private:', + ' struct Node;', + ' Node* head_;', + '};', + 'template ', + 'struct SkipList::Node {', + ' Node* Next(int n) { return nullptr; }', + ' void SetNext(int n, Node* x) {}', + '};', + 'class Table {', + ' public:', + ' int GlobalSeqno() const { return rep_->get_global_seqno(); }', + ' private:', + ' struct Rep;', + ' Rep* rep_;', + '};', + 'struct Table::Rep {', + ' int get_global_seqno() const { return 0; }', + '};', + '// Another class with methods of these names, so a guess by the name can miss.', + 'class MemTableRep {', + ' public:', + ' void* Next(int n) { return nullptr; }', + ' void SetNext(int n, void* x) {}', + ' int get_global_seqno() const { return 1; }', + '};', + '} // namespace leveldb', +); + +describe('C++ classes named with a scope or template arguments', () => { + it('names an out-of-line nested class, qualified with its members under its scope', async () => { + const cg = await indexed({ + 'db/skiplist.h': SKIPLIST, + // rocksdb's thread pool hides its state in a `class` defined in its source file. + 'util/threadpool.h': lines( + 'namespace rocksdb {', + 'class ThreadPoolImpl {', + ' public:', + ' int Size() const;', + ' private:', + ' class Impl;', + ' Impl* impl_;', + '};', + '} // namespace rocksdb', + ), + 'util/threadpool.cc': lines( + '#include "util/threadpool.h"', + 'namespace rocksdb {', + 'class ThreadPoolImpl::Impl {', + ' public:', + ' int GetQueueLen() const { return 0; }', + '};', + 'int ThreadPoolImpl::Size() const { return impl_->GetQueueLen(); }', + '} // namespace rocksdb', + ), + }); + try { + const all = classes(cg); + expect(all['struct leveldb::SkipList::Node']).toEqual(['leveldb::SkipList::Node::Next', 'leveldb::SkipList::Node::SetNext']); + expect(all['struct leveldb::Table::Rep']).toEqual(['leveldb::Table::Rep::get_global_seqno']); + expect(all['class rocksdb::ThreadPoolImpl::Impl']).toEqual(['rocksdb::ThreadPoolImpl::Impl::GetQueueLen']); + expect(Object.keys(all).filter((k) => k.includes(''))).toEqual([]); + } finally { + cg.close(); + } + }); + + it("leaves a specialization out of its template's name", async () => { + // Named `formatter`, fmt's specializations stood in for the template and + // for each other: `formatter : formatter` + // extended `formatter`. + const cg = await indexed({ + 'include/fmt/base.h': lines( + 'namespace fmt {', + 'template struct formatter {', + ' formatter() = delete;', + '};', + 'template <> struct formatter {', + ' auto format(int x) const { return x; }', + '};', + '} // namespace fmt', + ), + 'test/format-test.cc': lines( + '#include "include/fmt/base.h"', + 'struct Answer {};', + 'template <> struct fmt::formatter : fmt::formatter {};', + ), + }); + try { + const named = [...cg.getNodesByKind('struct'), ...cg.getNodesByKind('class')] + .filter((n) => n.name === 'formatter' || n.qualifiedName.includes('formatter')); + expect(named.map((n) => `${n.qualifiedName} ${n.filePath}:${n.startLine}`)).toEqual(['fmt::formatter include/fmt/base.h:2']); + } finally { + cg.close(); + } + }); + + it('leaves a class unnamed when its scope is no plain name', async () => { + const cg = await indexed({ + 'outer.h': lines( + 'namespace ns {', + 'template struct Outer { struct Inner; struct Other; };', + 'template struct Outer<(N > 1)>::Inner { int x; };', + 'struct decltype(Outer<1>())::Other { int y; };', + '} // namespace ns', + ), + }); + try { + expect(Object.keys(classes(cg)).sort()).toEqual(['struct ns::', 'struct ns::Outer']); + } finally { + cg.close(); + } + }); + + it("links a call on such a class's pointer to its method", async () => { + const cg = await indexed({ 'db/skiplist.h': SKIPLIST }); + try { + expect(targets(cg, 'leveldb::SkipList::Insert', 'calls')).toEqual(['leveldb::SkipList::Node::Next @0.9', 'leveldb::SkipList::Node::SetNext @0.9']); + expect(targets(cg, 'leveldb::Table::GlobalSeqno', 'calls')).toEqual(['leveldb::Table::Rep::get_global_seqno @0.9']); + } finally { + cg.close(); + } + }); +}); + +/** What a function's or method's `kind` edges reach, as `qualifiedName @confidence`. */ +function targets(cg: CodeGraph, qualifiedName: string, kind: 'calls' | 'instantiates'): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], [kind]) + .map((e) => `${cg.getNode(e.target)?.qualifiedName} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +describe('C++ constructions of a type a class shares its name with', () => { + // leveldb's db_impl.cc constructs `log::Writer`s and, as `Writer w(…)`, the + // `DBImpl::Writer` it defines out of line: by the type's last name alone, + // the two were one. + const LEVELDB = { + 'db/log_writer.h': lines( + 'namespace leveldb {', + 'namespace log {', + 'class Writer {', + ' public:', + ' explicit Writer(int* dest) {}', + '};', + '} // namespace log', + '} // namespace leveldb', + ), + 'db/db_impl.h': lines( + '#include "db/log_writer.h"', + 'namespace leveldb {', + 'class DBImpl {', + ' public:', + ' void NewDB();', + ' void Write();', + ' private:', + ' struct Writer;', + ' log::Writer* log_;', + '};', + '} // namespace leveldb', + ), + 'db/db_impl.cc': lines( + '#include "db/db_impl.h"', + 'namespace leveldb {', + 'struct DBImpl::Writer {', + ' explicit Writer(int* mu) {}', + '};', + 'void DBImpl::NewDB() {', + ' int file = 0;', + ' log_ = new log::Writer(&file);', + ' const log::Writer scratch(&file);', + '}', + 'void DBImpl::Write() {', + ' int mu = 0;', + ' Writer w(&mu);', + '}', + '} // namespace leveldb', + ), + 'db/log_test.cc': lines( + '#include "db/log_writer.h"', + 'namespace leveldb {', + 'namespace log {', + 'class LogTest {', + ' public:', + ' void Reset() { writer_ = new Writer(nullptr); }', + ' private:', + ' Writer* writer_;', + '};', + '} // namespace log', + '} // namespace leveldb', + ), + }; + + it('makes the class C++ lookup finds for the type as written', async () => { + const cg = await indexed(LEVELDB); + try { + expect(targets(cg, 'leveldb::DBImpl::NewDB', 'instantiates')).toEqual(['leveldb::log::Writer @0.9', 'leveldb::log::Writer @0.9']); + expect(targets(cg, 'leveldb::DBImpl::Write', 'instantiates')).toEqual(['leveldb::DBImpl::Writer @0.9']); + expect(targets(cg, 'leveldb::log::LogTest::Reset', 'instantiates')).toEqual(['leveldb::log::Writer @0.9']); + } finally { + cg.close(); + } + }); + + it("tells apart two classes' nested classes of one name", async () => { + // rocksdb's `new BlockBasedTable::Rep(…)` reached `BlockBasedTableBuilder::Rep`. + const cg = await indexed({ + 'table/reader.h': lines( + 'namespace rocksdb {', + 'class BlockBasedTable {', + ' public:', + ' static void Open();', + ' struct Rep;', + '};', + 'struct BlockBasedTable::Rep {', + ' explicit Rep(int level) {}', + '};', + '} // namespace rocksdb', + ), + 'table/reader.cc': lines( + '#include "table/reader.h"', + 'namespace rocksdb {', + 'void BlockBasedTable::Open() {', + ' Rep* rep = new BlockBasedTable::Rep(0);', + '}', + '} // namespace rocksdb', + ), + 'table/builder.cc': lines( + '#include "table/reader.h"', + 'namespace rocksdb {', + 'class BlockBasedTableBuilder {', + ' public:', + ' void Finish();', + ' private:', + ' struct Rep;', + ' Rep* rep_;', + '};', + 'struct BlockBasedTableBuilder::Rep {', + ' explicit Rep(int level) {}', + '};', + 'void BlockBasedTableBuilder::Finish() {', + ' rep_ = new Rep(1);', + ' BlockBasedTable::Rep* other = new BlockBasedTable::Rep(2);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(targets(cg, 'rocksdb::BlockBasedTable::Open', 'instantiates')).toEqual(['rocksdb::BlockBasedTable::Rep @0.9']); + expect(targets(cg, 'rocksdb::BlockBasedTableBuilder::Finish', 'instantiates')) + .toEqual(['rocksdb::BlockBasedTable::Rep @0.9', 'rocksdb::BlockBasedTableBuilder::Rep @0.9']); + } finally { + cg.close(); + } + }); + + it('makes an enum, the class a local alias names, or nothing, where lookup finds no class', async () => { + // rocksdb declares a `Comparator` class, and its skip lists take one as a + // template parameter; protobuf's `CounterMap()` makes a `std::map` it + // calls `Map`, beside protobuf's own `Map`. + const cg = await indexed({ + 'util/counters.cc': lines( + '#include ', + '#include "util/comparator.h"', + 'namespace rocksdb {', + 'void* CounterMap() {', + ' using Bytes = std::map;', + ' return new Bytes{};', + '}', + 'void* Buffer() { return new Bytes(1); }', + 'void* Owned() {', + ' using Comparator = Bytes;', + ' return new Comparator(2);', + '}', + '} // namespace rocksdb', + ), + 'util/comparator.h': lines( + 'namespace rocksdb {', + 'class Comparator {', + ' public:', + ' explicit Comparator(int n) {}', + '};', + 'class Bytes {', + ' public:', + ' explicit Bytes(int n) {}', + '};', + 'class value_type {', + ' public:', + ' explicit value_type(char c) {}', + '};', + 'enum ValueType { kTypeValue, kTypeDeletion };', + '} // namespace rocksdb', + ), + 'memtable/skiplist.h': lines( + '#include ', + '#include "util/comparator.h"', + 'namespace rocksdb {', + 'namespace memtable {', + 'using Bytes = std::string;', + 'template ', + 'class SkipList {', + ' public:', + ' void Insert() {', + ' Comparator compare(1);', + ' Bytes key(8);', + " Bytes::value_type first('a');", + ' const ValueType types[] = {kTypeValue};', + ' }', + '};', + 'template ', + 'void Fill() { ValueType value(1); }', + '} // namespace memtable', + '} // namespace rocksdb', + ), + // An enum of the same name another source file declares for itself. + 'util/a.cc': lines( + 'namespace rocksdb {', + 'enum ValueType { kOther };', + '} // namespace rocksdb', + ), + }); + try { + expect(targets(cg, 'rocksdb::memtable::SkipList::Insert', 'instantiates')).toEqual(['rocksdb::ValueType @0.9', 'rocksdb::memtable::Bytes @0.9']); + const insert = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'rocksdb::memtable::SkipList::Insert')!; + expect(cg.getOutgoingEdgesFrom([insert.id], ['instantiates']).map((e) => cg.getNode(e.target)!) + .filter((n) => n.kind === 'enum').map((n) => n.filePath)).toEqual(['util/comparator.h']); + expect(targets(cg, 'rocksdb::memtable::Fill', 'instantiates')).toEqual([]); + expect(targets(cg, 'rocksdb::CounterMap', 'instantiates')).toEqual([]); + expect(targets(cg, 'rocksdb::Buffer', 'instantiates')).toEqual(['rocksdb::Bytes @0.9']); + expect(targets(cg, 'rocksdb::Owned', 'instantiates')).toEqual(['rocksdb::Bytes @0.9']); + } finally { + cg.close(); + } + }); + + it('takes the class only the file sees at less than C++ lookup', async () => { + const cg = await indexed({ + 'widget.h': lines( + 'namespace ui {', + 'class Widget {', + ' public:', + ' explicit Widget(int n) {}', + '};', + '} // namespace ui', + ), + 'main.cc': lines( + '#include "widget.h"', + 'class App {', + ' public:', + ' void Run() { Widget w(1); }', + '};', + ), + }); + try { + expect(targets(cg, 'App::Run', 'instantiates')).toEqual(['ui::Widget @0.7']); + } finally { + cg.close(); + } + }); + + it("reads the type's template arguments, and tells same-named local classes apart by where they are", async () => { + const cg = await indexed({ + 'util/writer_test.cc': lines( + 'namespace rocksdb {', + 'template class Outer {', + ' public:', + ' class Inner {};', + '};', + 'class WriterTest {', + ' public:', + ' void Make() {', + ' auto* a = new Outer::Inner();', + ' auto* b = new Outer<(sizeof(int) > 2)>::Inner();', + ' }', + '};', + 'TEST_F(WriterTest, RangeSync) {', + ' class FakeWF {', + ' public:', + ' explicit FakeWF(int n) {}', + ' };', + ' FakeWF* wf = new FakeWF(1);', + '}', + 'TEST_F(WriterTest, IncrementalBuffer) {', + ' class FakeWF {', + ' public:', + ' explicit FakeWF(int n) {}', + ' };', + ' FakeWF* wf = new FakeWF(2);', + '}', + '} // namespace rocksdb', + ), + // googlemock's specializations, unnamed, each nest an `Impl` and an alias. + 'gmock/gmock.h': lines( + 'namespace testing {', + 'class Matchers {', + ' template class RefMatcher;', + ' template class RefMatcher {', + ' public:', + ' using Tuple = std::tuple;', + ' class Impl {};', + " // The next specialization's `Tuple` and `Impl` are nearer to", + ' // this `Make` than its own.', + ' //', + ' //', + ' //', + ' void* Make() { Tuple t(1); return new Impl(); }', + ' };', + ' template class RefMatcher {', + ' public:', + ' using Tuple = std::tuple;', + ' class Impl {};', + ' void* Make() { Tuple t(nullptr); return new Impl(); }', + ' };', + '};', + '} // namespace testing', + ), + }); + try { + // (Recorded by the name before its first `<`, `Outer`.) + expect(targets(cg, 'rocksdb::WriterTest::Make', 'instantiates')).toEqual(['rocksdb::Outer::Inner @0.9', 'rocksdb::Outer::Inner @0.9']); + const made = (callers: Node[]): string[][] => callers.sort((a, b) => a.startLine - b.startLine) + .map((fn) => cg.getOutgoingEdgesFrom([fn.id], ['instantiates']).map((e) => `${fn.startLine} -> ${cg.getNode(e.target)?.startLine}`).sort()); + expect(made(cg.getNodesByKind('function').filter((n) => n.name === 'TEST_F'))).toEqual([['13 -> 14'], ['20 -> 21']]); + expect(made(cg.getNodesByKind('method').filter((n) => n.filePath === 'gmock/gmock.h'))) + .toEqual([['13 -> 6', '13 -> 7'], ['19 -> 17', '19 -> 18']]); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/kernel-ccpp-parity.test.ts b/__tests__/kernel-ccpp-parity.test.ts index 33baf912ec..bbb9a838d1 100644 --- a/__tests__/kernel-ccpp-parity.test.ts +++ b/__tests__/kernel-ccpp-parity.test.ts @@ -122,6 +122,43 @@ describe.skipIf(!kernelBuilt)('kernel C/C++ extraction parity', () => { expect(names).not.toContain('registration'); }); + it.each(['\n', '\r\n'])('classes named with a scope or template arguments retain parity (%j)', (eol) => { + const source = [ + 'namespace leveldb {', + 'template ', + 'class SkipList { struct Node; Node* head_; };', + 'template ', + 'struct SkipList::Node {', + ' Node* Next(int n) { return nullptr; }', + ' struct Link { int hops() const { return 0; } };', + '};', + 'class Table { struct Rep; };', + 'struct Table::Rep { int seqno() const { return 0; } };', + 'namespace leveldb {', + 'struct leveldb::Table::Rep2 {};', + '}', + 'struct ::Top { int depth() const { return 0; } };', + '} // namespace leveldb', + 'template ', + 'class FunctionMocker { R Invoke(Args... args) { return R(); } };', + 'template <> struct fmt::formatter { int format(int x) const { return x; } };', + 'union ::Global::Bits { int all; };', + 'struct Tag;', + 'struct ::Tag { int bits() const { return 0; } };', + '', + ].join(eol); + assertParity('scoped-classes.h', source, 'cpp', 10); + const result = tryKernelExtract('scoped-classes.h', source, 'cpp')!; + const named = (kind: string) => result.nodes.filter((n) => n.kind === kind).map((n) => n.qualifiedName).sort(); + // A name written from the global scope alone has no scope to be qualified under. + expect(result.nodes.map((n) => n.qualifiedName).filter((q) => q.startsWith('::') || q.includes('::::'))).toEqual([]); + expect(named('struct')).toEqual(expect.arrayContaining(['leveldb::SkipList::Node', 'leveldb::SkipList::Node::Link', 'leveldb::Table::Rep', 'leveldb::Table::Rep2'])); + expect(named('union')).toEqual(['Global::Bits']); + expect(named('method')).toEqual(expect.arrayContaining(['leveldb::SkipList::Node::Next', 'leveldb::SkipList::Node::Link::hops', 'leveldb::Table::Rep::seqno'])); + // A specialization is not named after its template. + expect([...named('struct'), ...named('class')].filter((q) => /formatter|FunctionMocker/.test(q))).toEqual([]); + }); + it.each(['\n', '\r\n'])('COM interface declarations retain native/wasm parity (%j)', (eol) => { const source = [ '#define interface struct', diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index 415ca3bca0..f6d5fa7d3d 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -301,6 +301,10 @@ struct Scope { row: u32, kind: &'static str, name: String, + /// The qualified name its members are qualified under in place of the + /// scopes around it: a class written in a scope (objectPathOwners in + /// tree-sitter.ts, written_scope_qualified_name). + qualified: Option, } #[derive(Default)] @@ -464,7 +468,7 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result Walker<'t> { let qualified = extra.qualified_name.unwrap_or_else(|| { let mut parts: Vec<&str> = self.namespace_prefix.iter().map(|s| s.as_str()).collect(); for s in &self.stack { - if s.kind != "file" { + if let Some(q) = &s.qualified { + parts.clear(); + parts.push(q); + } else if s.kind != "file" { parts.push(&s.name); } } @@ -648,6 +655,11 @@ impl<'t> Walker<'t> { if let Some(name) = self.unnamed_type_variable_name(node) { return name; } + if self.variant == Variant::Cpp { + if let Some(mut parts) = self.cpp_written_class_parts(node) { + return parts.pop().unwrap_or_default(); + } + } if let Some(name_node) = node.child_by_field_name("declarator") { let mut resolved = name_node; // Unwrap pointer/reference declarators (`int* f()`, `T& f()`). @@ -688,6 +700,55 @@ impl<'t> Walker<'t> { "".to_string() } + /// cppWrittenClassParts (languages/c-cpp.ts): the `::` segments of a class, + /// struct or union name written with a scope, the scope's template + /// arguments dropped (`["SkipList", "Node"]` for `struct SkipList::Node`). None for a plain name and for a specialization, + /// whose innermost name has template arguments. + fn cpp_written_class_parts(&self, node: Node) -> Option> { + if !matches!(node.kind(), "class_specifier" | "struct_specifier" | "union_specifier") { + return None; + } + let written = node.child_by_field_name("name")?; + if written.kind() != "qualified_identifier" { + return None; + } + let mut innermost = Some(written); + while let Some(n) = innermost.filter(|n| n.kind() == "qualified_identifier") { + innermost = n.child_by_field_name("name"); + } + if innermost.map(|n| n.kind()) != Some("type_identifier") { + return None; + } + let parts: Vec = strip_cpp_template_args(self.text(written)) + .split("::") + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + let identifier = |p: &String| { + let mut chars = p.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + }; + if parts.is_empty() || !parts.iter().all(identifier) { + return None; + } + Some(parts) + } + + /// writtenScopeQualifiedName (tree-sitter.ts): a class whose name is + /// written in a scope is qualified like an out-of-line method under it. + fn written_scope_qualified_name(&self, node: Node, name: &str) -> Option { + if self.variant != Variant::Cpp { + return None; + } + let parts = self.cpp_written_class_parts(node)?; + if parts.len() < 2 { + return None; + } + Some(self.compose_receiver_qualified_name(&parts[..parts.len() - 1].join("::"), name)) + } + /// unnamedTypeVariableName (languages/c-cpp.ts): an unnamed class, struct, /// union or enum defined in a declaration takes the name of the first /// variable the declaration declares (`static struct { … } SPT;` → `SPT`). @@ -1172,7 +1233,7 @@ impl<'t> Walker<'t> { // for c/cpp: not in TYPE_ANNOTATION_LANGUAGES, and the decorator node // kinds never appear as direct children/preceding siblings in these // grammars — `attribute` only occurs under attribute_declaration.) - self.stack.push(Scope { row, kind: "function", name }); + self.stack.push(Scope { row, kind: "function", name, qualified: None }); if let Some(body) = node.child_by_field_name("body") { self.visit_function_body(body); } @@ -1242,7 +1303,7 @@ impl<'t> Walker<'t> { } } - self.stack.push(Scope { row, kind: "method", name }); + self.stack.push(Scope { row, kind: "method", name, qualified: None }); if let Some(body) = node.child_by_field_name("body") { self.visit_function_body(body); } @@ -1260,14 +1321,16 @@ impl<'t> Walker<'t> { stack_guard!(); let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); + let scoped = self.written_scope_qualified_name(node, &name); let extra = Extra { docstring: self.class_like_docstring(node), visibility: self.visibility_of(node), end: self.brace_body_end(body), + qualified_name: scoped.clone(), ..Extra::default() }; let Some(row) = self.create_node("class", &name, node, extra) else { return }; - let scope = Scope { row, kind: "class", name }; + let scope = Scope { row, kind: "class", name, qualified: scoped }; self.open_class_scope(&scope, body); self.extract_inheritance(node, row); self.stack.push(scope); @@ -1284,14 +1347,16 @@ impl<'t> Walker<'t> { stack_guard!(); let Some(body) = node.child_by_field_name("body") else { return }; let name = self.extract_name(node); + let scoped = self.written_scope_qualified_name(node, &name); let extra = Extra { docstring: self.class_like_docstring(node), visibility: if self.variant == Variant::Cpp { self.visibility_of(node) } else { None }, end: self.brace_body_end(body), + qualified_name: scoped.clone(), ..Extra::default() }; let Some(row) = self.create_node(kind, &name, node, extra) else { return }; - let scope = Scope { row, kind, name }; + let scope = Scope { row, kind, name, qualified: scoped }; self.open_class_scope(&scope, body); self.extract_inheritance(node, row); self.stack.push(scope); @@ -1314,7 +1379,7 @@ impl<'t> Walker<'t> { }; let Some(row) = self.create_node("enum", &name, node, extra) else { return }; self.extract_inheritance(node, row); - self.stack.push(Scope { row, kind: "enum", name }); + self.stack.push(Scope { row, kind: "enum", name, qualified: None }); for i in 0..body.named_child_count() { let Some(child) = body.named_child(i) else { continue }; if child.kind() == "enumerator" { @@ -1375,7 +1440,7 @@ impl<'t> Walker<'t> { ) else { return true; }; - self.stack.push(Scope { row, kind, name }); + self.stack.push(Scope { row, kind, name, qualified: None }); let type_child = node .child_by_field_name("type") .or_else(|| self.find_child_by_kind(node, "struct_specifier")) @@ -1402,7 +1467,7 @@ impl<'t> Walker<'t> { ) else { return true; }; - self.stack.push(Scope { row, kind: "enum", name }); + self.stack.push(Scope { row, kind: "enum", name, qualified: None }); if let Some(inner) = self.find_child_by_kind(node, "enum_specifier") { self.extract_inheritance(inner, row); if let Some(body) = inner.child_by_field_name("body") { diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index f2161c6fa9..69d9a5c6ca 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -192,6 +192,31 @@ function recoverCppMacroDefinedName(node: SyntaxNode, source: string): string | return name; } +const CPP_CLASS_SPECIFIERS: ReadonlySet = new Set(['class_specifier', 'struct_specifier', 'union_specifier']); + +/** + * The `::` segments of a C++ class, struct or union name written with a + * scope, the scope's template arguments dropped: `['SkipList', 'Node']` for + * an out-of-line nested class `struct SkipList::Node`. Such + * a class was indexed ``, so its members were no class's. + * Undefined for a plain name, and for a specialization (`class + * FunctionMocker`, `struct fmt::formatter`): named after its + * template, it would stand in for the template and every other + * specialization of it (fmt's `formatter : formatter` extended `formatter`). Mirrored in the kernel + * (ccpp/mod.rs). + */ +function cppWrittenClassParts(node: SyntaxNode, source: string): string[] | undefined { + if (!CPP_CLASS_SPECIFIERS.has(node.type)) return undefined; + const written = getChildByField(node, 'name'); + if (written?.type !== 'qualified_identifier') return undefined; + let innermost: SyntaxNode | null = written; + while (innermost?.type === 'qualified_identifier') innermost = getChildByField(innermost, 'name'); + if (innermost?.type !== 'type_identifier') return undefined; + const parts = stripCppTemplateArgs(getNodeText(written, source)).split('::').map((p) => p.trim()).filter(Boolean); + return parts.length > 0 && parts.every((p) => /^[A-Za-z_]\w*$/.test(p)) ? parts : undefined; +} + function extractCppQualifiedMethodName(node: SyntaxNode, source: string): string | undefined { const macroDefined = recoverSingleArgMacroDefinedName(node, source) ?? recoverCppMacroDefinedName(node, source); if (macroDefined) return macroDefined; @@ -2400,9 +2425,10 @@ export const cppExtractor: LanguageExtractor = { bodyField: 'body', paramsField: 'parameters', resolveName: (node, source) => - extractCppQualifiedMethodName(node, source) ?? unnamedTypeVariableName(node, source), + extractCppQualifiedMethodName(node, source) ?? unnamedTypeVariableName(node, source) ?? cppWrittenClassParts(node, source)?.at(-1), getDeclarationWrapper: (node) => definingDeclaration(node) ?? undefined, getReceiverType: extractCppReceiverType, + getClassScope: (node, source) => cppWrittenClassParts(node, source)?.slice(0, -1).join('::') || undefined, getReturnType: extractCppReturnType, // Constructors (definitions and class-body declarations) carry their // parameter list as the signature; a trailing semicolon marks a prototype, so a local `T obj(args)` can be matched diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index e665b7d3dc..3a695d52d2 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -314,6 +314,14 @@ export interface LanguageExtractor { */ getReceiverType?: (node: SyntaxNode, source: string) => string | undefined; + /** + * The scope a class, struct or union declaration writes its name in, when + * it does: C++'s `SkipList` for an out-of-line nested class `struct + * SkipList::Node`. The class is qualified under it the way + * an out-of-line method is under its receiver, and so are its members. + */ + getClassScope?: (node: SyntaxNode, source: string) => string | undefined; + /** * Extract a function/method's normalized return type name (bare class name, * smart-pointer pointee unwrapped), stored on the node as `returnType`. Used diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 165885eda2..0422c44c54 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1819,6 +1819,18 @@ export class TreeSitterExtractor { return prefix.length > 0 ? `${prefix.join('::')}::${base}` : base; } + /** + * The qualified name of a class, struct or union whose declaration writes + * its name in a scope (getClassScope): C++'s out-of-line nested `struct + * SkipList::Node` is `leveldb::SkipList::Node`, composed + * like an out-of-line method's. Its members are qualified under it + * (objectPathOwners). Undefined for any other. + */ + private writtenScopeQualifiedName(node: SyntaxNode, name: string): string | undefined { + const scope = this.extractor?.getClassScope?.(node, this.source); + return scope ? this.composeReceiverQualifiedName(scope, name) : undefined; + } + /** * Build qualified name from node stack */ @@ -2140,14 +2152,17 @@ export class TreeSitterExtractor { const docstring = this.docstringFor(node); const visibility = this.extractor.getVisibility?.(node); const isExported = this.extractor.isExported?.(node, this.source); + const scoped = this.writtenScopeQualifiedName(node, name); const classNode = this.createNode(kind, name, node, { docstring, visibility, isExported, ...this.cppBodyEnd(resolvedBody), + ...(scoped !== undefined ? { qualifiedName: scoped } : {}), }); if (!classNode) return; + if (scoped !== undefined) this.objectPathOwners.set(classNode.id, scoped); this.openCppClassScope(classNode, resolvedBody); // Extract extends/implements @@ -2356,14 +2371,17 @@ export class TreeSitterExtractor { const docstring = this.docstringFor(node); const visibility = this.extractor.getVisibility?.(node); const isExported = this.extractor.isExported?.(node, this.source); + const scoped = this.writtenScopeQualifiedName(node, name); const aggregateNode = this.createNode(kind, name, node, { docstring, visibility, isExported, ...this.cppBodyEnd(body), + ...(scoped !== undefined ? { qualifiedName: scoped } : {}), }); if (!aggregateNode) return; + if (scoped !== undefined) this.objectPathOwners.set(aggregateNode.id, scoped); this.openCppClassScope(aggregateNode, body); // Extract inheritance (e.g. Swift: struct HTTPMethod: RawRepresentable) diff --git a/src/resolution/cpp-type-aliases.ts b/src/resolution/cpp-type-aliases.ts index 3fbd1cc5ca..f4bbbbbe4b 100644 --- a/src/resolution/cpp-type-aliases.ts +++ b/src/resolution/cpp-type-aliases.ts @@ -81,12 +81,16 @@ function cppTypeName(raw: string): TypeName | null { const parts: string[] = []; const templated: boolean[] = []; let depth = 0; + let parens = 0; let part = ''; let args = false; const text = raw.replace(/\b(?:const|volatile|mutable|typename|template|class|struct|union|enum)\b/g, ' ').replace(/[&*]+/g, ' '); for (let i = 0; i < text.length; i++) { const c = text[i]!; - if (c === '<') { + // (In template arguments, a `<` or `>` in parentheses compares: `Bits<(N > 8)>`.) + if (depth > 0 && (c === '(' || c === ')')) parens = Math.max(0, parens + (c === '(' ? 1 : -1)); + else if (parens > 0) continue; + else if (c === '<') { depth++; args = true; } else if (c === '>') depth = Math.max(0, depth - 1); @@ -254,6 +258,17 @@ function localAliasLines(caller: Node, lines: readonly string[], context: Resolu return found; } +/** + * Does the function making `ref` declare, before it, an alias of the name a + * written type starts with — `using Map = std::map<…>;` for `Map`? Such an + * alias isn't indexed, so a lookup goes past it; resolveCppAliasedType reads it. + */ +export function cppDeclaresLocalAlias(written: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const name = cppTypeName(written); + return !!name && !written.trimStart().startsWith('::') && + localAliasedType(name.names[0]!, context.getNodeById?.(ref.fromNodeId), ref, context) !== undefined; +} + /** * The type an alias declared earlier in the calling function names, as * written. Those aliases are not indexed, so the function's own lines are diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ed9ef9643b..f95a076b5e 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14,7 +14,7 @@ import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-vis import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, 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, cppParentScope, cppScopesWithin, cppTemplateArguments, cppTemplateParameters, cppTypeSegments, isCppPointerType, resolveCppAliasedType, stripCppTemplateArguments, type CppAliasedType } from './cpp-type-aliases'; +import { cppAliasedClass, cppAliasedTypeName, cppAliasedTypeWritten, cppDeclaresLocalAlias, cppParentScope, cppScopesWithin, cppTemplateArguments, cppTemplateParameters, cppTypeSegments, isCppPointerType, resolveCppAliasedType, stripCppTemplateArguments, type CppAliasedType } from './cpp-type-aliases'; import { clearCppNamespaceMemos, cppMacroNamespaceFrames, cppMacroNamespaceHeads, cppNamespaceAliases } from './cpp-namespaces'; import { clearCppRawStringMemos, cppLiteralsBlanked, cppRawStringLines } from './cpp-raw-strings'; import { cppClassWritten, type CppNameSite } from './cpp-supertypes'; @@ -11346,6 +11346,133 @@ function inferCppAutoInitializerType( return null; } +/** + * A C++ construction — `new log::Writer(…)`, `Writer w(…)`, which the + * extractor records by the type's last name — makes the class C++ lookup + * finds for the type as written at the ref (cppClassWritten), as a base + * class names one (matchCppSupertype): from the scopes around the call + * outward, its qualifier followed, an alias to the class it names. leveldb's + * db_impl.cc writes `new log::Writer(…)` in `DBImpl` methods, which also + * construct `DBImpl::Writer`s, the struct it defines out of line, as `Writer + * w(…)`; by its last name alone the two were one. + * - At 0.7 when lookup doesn't place the type and the class is only the one + * of that name the file can see. + * - The enum lookup finds instead of a class: rocksdb's `const ValueType + * types[] = {kTypeValue, …}`. + * - Through an alias the calling function declares itself, which isn't + * indexed, the class it names, if one of the project's. + * - null (no edge) when the type names no particular class: a template + * parameter, a member of a type from outside the project (rocksdb's + * `stl_wrappers::KVMap::value_type`), a function's own alias of one — + * protobuf's `using Map = std::map<…>; … new Map{}` is no + * `google::protobuf::Map`. + * - undefined, for the name strategies to decide as before, when nothing + * the file sees is named so. + * The type is read with its template arguments, so `new Outer::Inner(…)`, + * which the extractor records by the name before the first `<`, makes an + * `Outer::Inner`. Where the file declares several classes or aliases of the + * qualified name lookup found, which C++ tells apart and qualified names + * don't, the type is the one declared around the construction + * (cppNamesakeAround). + */ +function matchCppConstruction(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + const caller = context.getNodeById?.(ref.fromNodeId); + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + const text = lines?.[ref.line - 1]?.slice(Math.max(0, ref.column)); + if (!caller || !text) return undefined; + // (A declaration's column is where its specifiers start: `const Slice key(…)`.) + const specifiers = /^(?:(?:new|const|volatile|static|thread_local|constexpr|constinit|mutable|struct|class|union|typename)\s+)*/.exec(text)![0]; + const written = cppTypeWrittenAt(text.slice(specifiers.length)); + if (!written) return undefined; + const made = (node: Node, exact = true): ResolvedRef => exact + ? { original: ref, targetNodeId: node.id, confidence: 0.9, resolvedBy: 'qualified-name' } + : { original: ref, targetNodeId: node.id, confidence: 0.7, resolvedBy: 'exact-match' }; + if (cppDeclaresLocalAlias(written, ref, context)) { + const aliased = resolveCppAliasedType(written, ref, context); + const named = aliased && !aliased.pointer ? cppAliasedClass(aliased, context) : undefined; + const cls = named ? context.getNodesByQualifiedName(named).find((n) => CPP_CLASS_KINDS.has(n.kind)) : undefined; + return cls ? made(cls) : null; + } + const site = cppCallSite(caller, ref, context); + const found = cppClassWritten(written, site, ref, context); + if (found === null) { + const named = cppEnumWritten(written, site, context); + return named ? made(named) : null; + } + if (!found) return undefined; + const namesakes = context.getNodesByQualifiedName(found.node.qualifiedName) + .filter((n) => n.filePath === ref.filePath && (CPP_CLASS_KINDS.has(n.kind) || n.kind === 'type_alias')); + const type = namesakes.length > 1 ? cppNamesakeAround(namesakes, ref, context) : found.node; + return type ? made(type, found.exact) : undefined; +} + +/** + * The enum a C++ type written at `site` names, which C++ lookup for a class + * passes by: the first scope around the site, innermost first, that declares + * one of that name the file can see. (A template parameter of that name is + * the parameter, which the resolver's type-parameter gate sees to.) + */ +function cppEnumWritten(written: string, site: CppNameSite, context: ResolutionContext): Node | undefined { + const global = written.trimStart().startsWith('::'); + const path = stripCppTemplateArguments(written).replace(/\s+/g, '').replace(/^::/, ''); + for (const scope of global ? [''] : cppScopesWithin(site.scope)) { + const enums = context.getNodesByQualifiedName(scope ? `${scope}::${path}` : path) + .filter((n) => n.kind === 'enum' && (n.filePath === site.file || !C_SOURCE_EXT.test(n.filePath))); + if (enums.length > 0) return enums.find((n) => n.filePath === site.file) ?? enums[0]; + } + return undefined; +} + +const CPP_HOLDER_KINDS: ReadonlySet = new Set(['function', 'method', 'class', 'struct', 'union']); + +/** + * Of several classes or aliases the ref's file declares under one qualified + * name — the local classes of rocksdb's gtest `TEST_F` bodies, each a + * `FakeWF`, or the `Impl`s and `ArgumentTuple`s of googlemock's unnamed + * specializations — the one whose holder (the function or class it is + * declared in, else the file) holds the ref. Undefined unless exactly one is. + */ +function cppNamesakeAround(namesakes: Node[], ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + const holders = context.getNodesInFile(ref.filePath).filter((n) => CPP_HOLDER_KINDS.has(n.kind)); + const around = namesakes.filter((cls) => { + let holder: Node | undefined; + for (const n of holders) { + if (n.id === cls.id || n.startLine > cls.startLine || n.endLine < cls.endLine) continue; + if (!holder || n.endLine - n.startLine < holder.endLine - holder.startLine) holder = n; + } + return !holder || (holder.startLine <= ref.line && ref.line <= holder.endLine); + }); + return around.length === 1 ? around[0] : undefined; +} + +/** + * The type name `text` starts with, template arguments and all: `log::Writer` + * of `log::Writer(…)`, `Outer::Inner` of `Outer::Inner x(…)`. + * Undefined when it starts with none, or a template argument list goes on to + * the next line. + */ +function cppTypeWrittenAt(text: string): string | undefined { + let end = 0; + for (let segment = /^(?:::\s*)?[A-Za-z_]\w*/; ; segment = /^\s*::\s*[A-Za-z_]\w*/) { + const name = segment.exec(text.slice(end)); + if (!name) return end > 0 ? text.slice(0, end) : undefined; + end += name[0].length; + const open = /^\s*` inside parentheses compares: `Bits<(N > 8)>`.) + for (let angles = 1, parens = 0; angles > 0; end++) { + const c = text[end]; + if (c === undefined) return undefined; + if (c === '(') parens++; + else if (c === ')') parens--; + else if (parens > 0) continue; + else if (c === '<') angles++; + else if (c === '>') angles--; + } + } +} + /** * Resolve a C++ chained call whose receiver is itself a call — encoded by the * extractor as `().` (#645). The receiver's type is what @@ -15849,6 +15976,13 @@ function matchReferenceInner( if (result) return result; } + // 1b'. A C++ construction — `new log::Writer(…)`, `Writer w(…)` — makes the + // class C++ lookup finds for the type as written there, or nothing. + if (ref.language === 'cpp' && ref.referenceKind === 'instantiates') { + const constructed = nmTimedT('cppConstruct', ref, () => matchCppConstruction(ref, context)); + if (constructed !== undefined) return constructed; + } + // 1c. `::`-scoped factory chain — PHP `Cls::for($x)->method()` (#608) or Rust // `Foo::new().bar()`, both encoded as `Cls::factory().method`. The receiver's // type is the factory's `self` (PHP `: self`/`: static`, Rust `-> Self`) or From 6df48276253238cfb0d806f97d9cb735f1daf622 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 06:30:36 +0000 Subject: [PATCH 08/29] fix(cpp): a call through a class reaches that class's own or inherited method, never a nested or namesake class's (#2548) The class-name and capitalized-receiver strategies took any method of the call's name whose qualified name contained the class's name, so protobuf's `descriptor->file()` reached `Descriptor::ExtensionRange::file` and rocksdb's `CompositeEnv::IsInstanceOf(name)` reached `CompositeEnvWrapper::IsInstanceOf`, while an explicit base call like `PessimisticTransaction::PopSavePoint()` never found the method the class inherits. In C and C++ a method is now the class's only when its qualified name is the class's plus the method's, and a call written through a class asks C++ lookup, bases included. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-class-name-receiver-members.test.ts | 116 ++++++++++++++++++ src/resolution/name-matcher.ts | 40 ++++-- 3 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 __tests__/cpp-class-name-receiver-members.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c01e09cbd..e2662b4181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C and C++, a call or a type name no longer links to something only another source file can see: a class, struct or alias a `.c` or `.cc` file declares for itself, its members, or anything inside that file's unnamed `namespace { … }`. In facebook/rocksdb, other test files' bare `Put(…)`, `Flush()` and `dbfull()` calls had linked to the methods of a test fixture declared in `column_family_test.cc`, and calls on an `Env*` had linked to a test-only environment class in `memenv.cc`. When a header and a source file each declare a class of the same name, like RocksDB's `SharedState`, a call now links to the header's. A source file's own declarations are still found from the file itself, from a file that `#include`s it, and from a header it includes below them. Re-index C and C++ projects after upgrading. - In C++, a call to an overloaded method now links to the overload its arguments fit: by how many there are, and then by their types. That covers a call on a variable or member of a known class, like facebook/rocksdb's `db->Get(ro, key, &value)` and `db->Put(write_opts, key, value)`, as well as a call written without a receiver, like `Insert(ch)` on a `char ch`. Before, a call on a variable always linked to the first overload declared, so RocksDB's convenience overloads of `DB::Get`, `DB::Put` and `DB::NewIterator` and protocolbuffers/protobuf's `io::Printer::Print`, `Emit` and `Annotate` had almost no callers, and a call without a receiver picked by argument count alone. A literal, a variable the calling function declares, `&variable`, `container[i]` and the result of a call are read for their types; a parameter's default value is read from the method's declaration in its class, and comments in argument lists are skipped. Re-index C++ projects after upgrading. - In C++, a nested class defined outside the class it belongs to, like google/leveldb's `struct SkipList::Node { … }` or facebook/rocksdb's `struct BlockBasedTable::Rep { … }`, is now indexed under its own name, `SkipList::Node`, with its methods and members. Before, it was indexed as an unnamed class, so it was missing from search and `codegraph_explore`, its methods belonged to no class you could name, and a call like `x->Next(level)` on a `Node*` was matched by the method's name alone. Creating an object, as in `new log::Writer(…)` or `Writer w(…)`, now links to the class C++ itself finds for the type as written there: leveldb's `new log::Writer(…)` in `DBImpl` methods no longer links to `DBImpl`'s own `Writer`, RocksDB's tests' `Random rnd(301)` links to RocksDB's `Random` instead of googletest's, an object created through an alias links to the class the alias names, and one links to its class rather than to the class's constructor. An object of a template parameter's type, or of a type the function names with its own `using`, like protocolbuffers/protobuf's `using Map = std::map<…>`, no longer links to a project class that only shares the name. Re-index C++ projects after upgrading. +- In C++, a call whose receiver's type can't be read no longer links to a method of a class nested in the class the receiver's name suggests, or of a class whose name only contains that name. Before, such a guess took any method of the call's name whose qualified name contained the class's name: protocolbuffers/protobuf's `descriptor->file()` in a template reached `Descriptor::ExtensionRange::file`, and facebook/rocksdb's `CompositeEnv::IsInstanceOf(name)` reached `CompositeEnvWrapper::IsInstanceOf`. A call written through a class, like `PessimisticTransaction::PopSavePoint()` in an override, now links to the method that class inherits, here `TransactionBaseImpl::PopSavePoint`. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-class-name-receiver-members.test.ts b/__tests__/cpp-class-name-receiver-members.test.ts new file mode 100644 index 0000000000..29418db9cc --- /dev/null +++ b/__tests__/cpp-class-name-receiver-members.test.ts @@ -0,0 +1,116 @@ +/** + * When a C++ call's receiver can't be typed, matchMethodCall guesses its + * class from the receiver's name: the class it names exactly (`Outer::Count()`) + * or its capitalized form (`arena.get()` → `Arena`). It then took any method + * of the call's name in that class's file whose qualified name merely + * contained the class's name. Once out-of-line nested classes were named, + * protocolbuffers/protobuf's `arena.get()` on a `std::unique_ptr` + * reached `Arena::UniquePtr::get`, a class nested in `Arena`, at 0.8; and a + * namesake like `ThreadSafeArena::get` matched the same way. In C and C++ the + * method must now be a member of exactly that class. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-class-receiver-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What the calls at `line` of the function or method `qualifiedName` reach, as `qualifiedName @confidence`. */ +function callsAt(cg: CodeGraph, qualifiedName: string, line: number): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .filter((e) => e.line === line) + .map((e) => `${cg.getNode(e.target)?.qualifiedName} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const ARENA = lines( + 'namespace google {', + 'namespace protobuf {', + 'class Arena {', + ' public:', + ' template class UniquePtr;', + ' void* AllocateAligned(int n) { return nullptr; }', + '};', + 'template ', + 'class Arena::UniquePtr {', + ' public:', + ' T* get() const { return nullptr; }', + '};', + 'class ThreadSafeArena {', + ' public:', + ' int get() const { return 0; }', + '};', + '} // namespace protobuf', + '} // namespace google', +); + +describe('C++ calls on a receiver named like a class', () => { + it("take only that class's own method, not a nested class's or a namesake's", async () => { + const cg = await indexed({ + 'arena.h': ARENA, + 'arena_test.cc': lines( + '#include "arena.h"', + 'namespace google {', + 'namespace protobuf {', + 'void* Run() {', + ' auto arena = MakeArena();', + ' Use(arena.get());', + ' return arena.AllocateAligned(8);', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(callsAt(cg, 'google::protobuf::Run', 6).filter((t) => /UniquePtr|ThreadSafeArena/.test(t) && t.endsWith('@0.8'))).toEqual([]); + expect(callsAt(cg, 'google::protobuf::Run', 7)).toEqual(['google::protobuf::Arena::AllocateAligned @0.8']); + } finally { + cg.close(); + } + }); + + it('reach a method the named class inherits before a nested class of the same name', async () => { + const cg = await indexed({ + 'outer.h': lines( + 'class Base {', + ' public:', + ' static int Count() { return 0; }', + '};', + 'class Outer : public Base {', + ' public:', + ' class Inner {', + ' public:', + ' static int Count() { return 1; }', + ' };', + '};', + ), + 'main.cc': lines( + '#include "outer.h"', + 'int Total() { return Outer::Count(); }', + ), + }); + try { + expect(callsAt(cg, 'Total', 2)).toEqual(['Base::Count @0.8']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f95a076b5e..8ccd2788c6 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8143,6 +8143,22 @@ function isMethodOwnerKind(n: Node): boolean { (n.language === 'scala' && n.kind === 'module'); } +/** + * Is `n` the method `name` of the class `cls` a receiver is taken to name + * (the class-name and capitalized-receiver strategies of matchMethodCall)? + * In C and C++, only one of exactly that class: protobuf's `arena.get()` + * took the `get` of `Arena::UniquePtr`, a class nested in `Arena`, since its + * qualified name has `Arena` in it, as `ThreadSafeArena::get`'s does. In + * other languages, any method of the name whose qualified name has the + * class's name in it, as before. + */ +function isClassStrategyMethod(n: Node, cls: Node, name: string): boolean { + if (n.kind !== 'method' || n.name !== name) return false; + return cls.language === 'c' || cls.language === 'cpp' + ? n.qualifiedName === `${cls.qualifiedName}::${name}` + : n.qualifiedName.includes(cls.name); +} + /** * When a symbol name is ambiguous across files, prefer the candidate(s) declared * in the call site's own file, keeping the rest in their original order (#1079). @@ -13348,13 +13364,18 @@ export function matchMethodCall( // Skip cross-language class matches if (classNode.language !== ref.language) continue; + // C++'s `Outer::Count()` is the member C++ looks up in `Outer`: its + // own, else one a base declares (cppMethodOf), never one of a class + // nested in it, like `Outer::Inner::Count` beside an inherited `Base::Count`. + if (classNode.language === 'cpp') { + const method = cppMethodOf(classNode, methodName!, ref, context); + if (!method) continue; + const own = method.qualifiedName === `${classNode.qualifiedName}::${methodName}`; + return { original: ref, targetNodeId: method.id, confidence: own ? 0.85 : 0.8, resolvedBy: 'qualified-name' }; + } + const nodesInFile = context.getNodesInFile(classNode.filePath); - const methodNode = nodesInFile.find( - (n) => - n.kind === 'method' && - n.name === methodName && - n.qualifiedName.includes(classNode.name) - ); + const methodNode = nodesInFile.find((n) => isClassStrategyMethod(n, classNode, methodName!)); if (methodNode) { return { @@ -13435,12 +13456,7 @@ export function matchMethodCall( if (classNode.language !== ref.language) continue; const nodesInFile = context.getNodesInFile(classNode.filePath); - const methodNode = nodesInFile.find( - (n) => - n.kind === 'method' && - n.name === methodName && - n.qualifiedName.includes(classNode.name) - ); + const methodNode = nodesInFile.find((n) => isClassStrategyMethod(n, classNode, methodName!)); if (methodNode) { return { From 754cc45ad714afe2f730e37214fb6d27379cceed Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 06:31:04 +0000 Subject: [PATCH 09/29] fix(cpp): an alias naming a class through a derived class leads to the class it inherits (#2549) protobuf's `using ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;` names a class the suite inherits from `ConformanceTestSuite`. An alias was followed only to a class declared under exactly the name it spells, so constructions through it stopped at the alias. Its target is now looked up a segment at a time through each class's bases, as C++ does. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-alias-inherited-member.test.ts | 151 +++++++++++++++++++ src/resolution/cpp-type-aliases.ts | 29 +++- src/resolution/name-matcher.ts | 2 +- 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 __tests__/cpp-alias-inherited-member.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2662b4181..7cc4c0f8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call to an overloaded method now links to the overload its arguments fit: by how many there are, and then by their types. That covers a call on a variable or member of a known class, like facebook/rocksdb's `db->Get(ro, key, &value)` and `db->Put(write_opts, key, value)`, as well as a call written without a receiver, like `Insert(ch)` on a `char ch`. Before, a call on a variable always linked to the first overload declared, so RocksDB's convenience overloads of `DB::Get`, `DB::Put` and `DB::NewIterator` and protocolbuffers/protobuf's `io::Printer::Print`, `Emit` and `Annotate` had almost no callers, and a call without a receiver picked by argument count alone. A literal, a variable the calling function declares, `&variable`, `container[i]` and the result of a call are read for their types; a parameter's default value is read from the method's declaration in its class, and comments in argument lists are skipped. Re-index C++ projects after upgrading. - In C++, a nested class defined outside the class it belongs to, like google/leveldb's `struct SkipList::Node { … }` or facebook/rocksdb's `struct BlockBasedTable::Rep { … }`, is now indexed under its own name, `SkipList::Node`, with its methods and members. Before, it was indexed as an unnamed class, so it was missing from search and `codegraph_explore`, its methods belonged to no class you could name, and a call like `x->Next(level)` on a `Node*` was matched by the method's name alone. Creating an object, as in `new log::Writer(…)` or `Writer w(…)`, now links to the class C++ itself finds for the type as written there: leveldb's `new log::Writer(…)` in `DBImpl` methods no longer links to `DBImpl`'s own `Writer`, RocksDB's tests' `Random rnd(301)` links to RocksDB's `Random` instead of googletest's, an object created through an alias links to the class the alias names, and one links to its class rather than to the class's constructor. An object of a template parameter's type, or of a type the function names with its own `using`, like protocolbuffers/protobuf's `using Map = std::map<…>`, no longer links to a project class that only shares the name. Re-index C++ projects after upgrading. - In C++, a call whose receiver's type can't be read no longer links to a method of a class nested in the class the receiver's name suggests, or of a class whose name only contains that name. Before, such a guess took any method of the call's name whose qualified name contained the class's name: protocolbuffers/protobuf's `descriptor->file()` in a template reached `Descriptor::ExtensionRange::file`, and facebook/rocksdb's `CompositeEnv::IsInstanceOf(name)` reached `CompositeEnvWrapper::IsInstanceOf`. A call written through a class, like `PessimisticTransaction::PopSavePoint()` in an override, now links to the method that class inherits, here `TransactionBaseImpl::PopSavePoint`. Re-index C++ projects after upgrading. +- In C++, an alias that names a class through a class derived from the one that declares it, like protocolbuffers/protobuf's `using ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;`, now leads to that class, `ConformanceTestSuite::ConformanceRequestSetting`. Before, an alias was followed only to a class declared under exactly the name it spells, so objects created through this one linked to the alias rather than the class. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-alias-inherited-member.test.ts b/__tests__/cpp-alias-inherited-member.test.ts new file mode 100644 index 0000000000..12774fa205 --- /dev/null +++ b/__tests__/cpp-alias-inherited-member.test.ts @@ -0,0 +1,151 @@ +/** + * A C++ alias can name a class one of a class's bases declares: + * protocolbuffers/protobuf's conformance suites write `using + * ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;`, + * a class their base `ConformanceTestSuite` declares. Following the alias + * looked the class up only by the qualified name as spelled, which no class + * has, so constructions through the alias stopped at the alias and calls on + * such a variable lost their class. The aliased type is now looked up the way + * a declared type is, a class's members through its bases too. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-alias-base-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What a function's or method's `kind` edges reach, as `qualifiedName @confidence`. */ +function targets(cg: CodeGraph, qualifiedName: string, kind: 'calls' | 'instantiates'): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], [kind]) + .map((e) => `${cg.getNode(e.target)?.qualifiedName} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const NS = 'google::protobuf::conformance'; +const SUITE = lines( + 'namespace google {', + 'namespace protobuf {', + 'namespace conformance {', + 'class ConformanceTestSuite {', + ' public:', + ' class ConformanceRequestSetting {', + ' public:', + ' ConformanceRequestSetting(int level, const char* name) {}', + ' const char* GetTestName() const { return ""; }', + ' };', + '};', + '} // namespace conformance', + '} // namespace protobuf', + '} // namespace google', +); + +describe('C++ aliases of a class a base declares', () => { + it('reach the class through an alias spelled through the derived class', async () => { + const cg = await indexed({ + 'conformance/conformance_test.h': SUITE, + 'conformance/binary_json_conformance_suite.h': lines( + '#include "conformance/conformance_test.h"', + 'namespace google {', + 'namespace protobuf {', + 'namespace conformance {', + 'class BinaryAndJsonConformanceSuite : public ConformanceTestSuite {};', + 'template ', + 'class BinaryAndJsonConformanceSuiteImpl {', + ' public:', + ' void RunTest();', + ' private:', + ' using ConformanceRequestSetting =', + ' BinaryAndJsonConformanceSuite::ConformanceRequestSetting;', + '};', + '} // namespace conformance', + '} // namespace protobuf', + '} // namespace google', + ), + 'conformance/binary_json_conformance_suite.cc': lines( + '#include "conformance/binary_json_conformance_suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'namespace conformance {', + 'template ', + 'void BinaryAndJsonConformanceSuiteImpl::RunTest() {', + ' ConformanceRequestSetting setting(1, "x");', + ' setting.GetTestName();', + '}', + '// An alias the function declares itself is not indexed: read from the function.', + 'void RunLocal() {', + ' using ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;', + ' ConformanceRequestSetting local(3, "w");', + ' local.GetTestName();', + '}', + '} // namespace conformance', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + const run = `${NS}::BinaryAndJsonConformanceSuiteImpl::RunTest`; + expect(targets(cg, run, 'instantiates')).toEqual([`${NS}::ConformanceTestSuite::ConformanceRequestSetting @0.9`]); + expect(targets(cg, run, 'calls').filter((t) => t.includes('GetTestName'))) + .toEqual([`${NS}::ConformanceTestSuite::ConformanceRequestSetting::GetTestName @0.9`]); + expect(targets(cg, `${NS}::RunLocal`, 'instantiates')).toEqual([`${NS}::ConformanceTestSuite::ConformanceRequestSetting @0.9`]); + expect(targets(cg, `${NS}::RunLocal`, 'calls').filter((t) => t.includes('GetTestName'))) + .toEqual([`${NS}::ConformanceTestSuite::ConformanceRequestSetting::GetTestName @0.9`]); + } finally { + cg.close(); + } + }); + + it("reach the class through an alias a derived class writes with the base's name alone", async () => { + const cg = await indexed({ + 'conformance/conformance_test.h': SUITE, + 'conformance/text_format_suite.h': lines( + '#include ', + '#include "conformance/conformance_test.h"', + 'namespace google {', + 'namespace protobuf {', + 'namespace conformance {', + 'class TextFormatSuite : public ConformanceTestSuite {', + ' public:', + ' using Setting = ConformanceRequestSetting;', + ' using Name = std::string;', + ' void Run() {', + ' Setting setting(2, "y");', + ' setting.GetTestName();', + ' Name name("z");', + ' }', + '};', + '} // namespace conformance', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + const run = `${NS}::TextFormatSuite::Run`; + // (An alias of a type from outside the project still makes the alias.) + expect(targets(cg, run, 'instantiates')) + .toEqual([`${NS}::ConformanceTestSuite::ConformanceRequestSetting @0.9`, `${NS}::TextFormatSuite::Name @0.9`]); + expect(targets(cg, run, 'calls').filter((t) => t.includes('GetTestName'))) + .toEqual([`${NS}::ConformanceTestSuite::ConformanceRequestSetting::GetTestName @0.9`]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/cpp-type-aliases.ts b/src/resolution/cpp-type-aliases.ts index f4bbbbbe4b..08fa66d17b 100644 --- a/src/resolution/cpp-type-aliases.ts +++ b/src/resolution/cpp-type-aliases.ts @@ -438,6 +438,22 @@ function baseClassesOf(cls: string, context: ResolutionContext): string[] { return bases; } +/** + * The qualified name of the class `names` spells, looked up from `scope`: + * spelled out in a scope around it (classNamed), else a segment at a time as + * a declared type is, so a class one of the named class's bases declares is + * found. protobuf's conformance suites alias `using ConformanceRequestSetting = + * BinaryAndJsonConformanceSuite::ConformanceRequestSetting;`, a class their + * base `ConformanceTestSuite` declares. + */ +function classReached(names: readonly string[], scope: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined { + const spelled = classNamed(names, scopesWithin(scope), context); + if (spelled) return spelled; + // (The walk stops on a class only once every segment is taken.) + const { found } = lookupSegments({ names: [...names], templated: names.map(() => false) }, scopesWithin(scope), ref, context); + return found && typeof found === 'object' && 'cls' in found ? found.cls : undefined; +} + /** The qualified name of the class `names` spells, looked up from `scopes`. */ function classNamed(names: readonly string[], scopes: readonly string[], context: ResolutionContext): string | undefined { const spelled = names.join('::'); @@ -521,9 +537,14 @@ export function cppAliasedTypeName(resolved: CppAliasedType): string { return [resolved.target[resolved.target.length - 1]!, ...resolved.rest].join('::'); } -/** The qualified name of the project class a type resolved through aliases is, if it is one: `leveldb::SkipList::Iterator`. */ -export function cppAliasedClass(resolved: CppAliasedType, context: ResolutionContext): string | undefined { - return classNamed([...resolved.target, ...resolved.rest], scopesWithin(resolved.scope), context); +/** + * The qualified name of the project class a type resolved through aliases is, + * if it is one: `leveldb::SkipList::Iterator`. With the ref it is resolved + * for, a class a base of the named class declares counts (classReached). + */ +export function cppAliasedClass(resolved: CppAliasedType, context: ResolutionContext, ref?: UnresolvedRef): string | undefined { + const names = [...resolved.target, ...resolved.rest]; + return ref ? classReached(names, resolved.scope, ref, context) : classNamed(names, scopesWithin(resolved.scope), context); } /** What a C++ type name names when it is looked up for a class (see cppClassNamed). */ @@ -573,7 +594,7 @@ export function cppClassNamed( // The aliased type is written in the alias's own scope. const aliased = expand(text ? aliasedTypeIn(text, alias.name) : null, name.names.slice(next), name.templated.slice(next), parentScope(alias.qualifiedName), ref, context, 1, templateParametersAround(alias, context)); - const cls = aliased && !aliased.pointer && classNamed([...aliased.target, ...aliased.rest], scopesWithin(aliased.scope), context); + const cls = aliased && !aliased.pointer && classReached([...aliased.target, ...aliased.rest], aliased.scope, ref, context); if (cls) return { cls }; // A member of a type outside the project (`Alias::Inner`) is not the alias. return next === name.names.length ? { alias } : null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 8ccd2788c6..ec463f6635 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -11405,7 +11405,7 @@ function matchCppConstruction(ref: UnresolvedRef, context: ResolutionContext): R : { original: ref, targetNodeId: node.id, confidence: 0.7, resolvedBy: 'exact-match' }; if (cppDeclaresLocalAlias(written, ref, context)) { const aliased = resolveCppAliasedType(written, ref, context); - const named = aliased && !aliased.pointer ? cppAliasedClass(aliased, context) : undefined; + const named = aliased && !aliased.pointer ? cppAliasedClass(aliased, context, ref) : undefined; const cls = named ? context.getNodesByQualifiedName(named).find((n) => CPP_CLASS_KINDS.has(n.kind)) : undefined; return cls ? made(cls) : null; } From d94a92ea89a361da4bfb9532885d468c68eaa576 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 06:31:30 +0000 Subject: [PATCH 10/29] fix(cpp): a gtest TEST_F or TEST_P body finds its fixture's types, and a function's local class is seen only from that function (#2551) A TEST_F / TEST_P body is a member function of a class derived from its fixture, so its unqualified names find the fixture's nested types (its bases' included) before the enclosing namespaces'. And since the index names a function's local class after the function, every TEST_F body's local `FakeWF` or `MyManager` shared one qualified name: a class local to a function is now visible only from inside that function, and a namesake class's methods are its own body's. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-gtest-fixture-scope.test.ts | 182 ++++++++++++++++++++++ src/resolution/cpp-supertypes.ts | 51 +++++- src/resolution/name-matcher.ts | 26 +++- 4 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 __tests__/cpp-gtest-fixture-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc4c0f8b4..f694b4f073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a nested class defined outside the class it belongs to, like google/leveldb's `struct SkipList::Node { … }` or facebook/rocksdb's `struct BlockBasedTable::Rep { … }`, is now indexed under its own name, `SkipList::Node`, with its methods and members. Before, it was indexed as an unnamed class, so it was missing from search and `codegraph_explore`, its methods belonged to no class you could name, and a call like `x->Next(level)` on a `Node*` was matched by the method's name alone. Creating an object, as in `new log::Writer(…)` or `Writer w(…)`, now links to the class C++ itself finds for the type as written there: leveldb's `new log::Writer(…)` in `DBImpl` methods no longer links to `DBImpl`'s own `Writer`, RocksDB's tests' `Random rnd(301)` links to RocksDB's `Random` instead of googletest's, an object created through an alias links to the class the alias names, and one links to its class rather than to the class's constructor. An object of a template parameter's type, or of a type the function names with its own `using`, like protocolbuffers/protobuf's `using Map = std::map<…>`, no longer links to a project class that only shares the name. Re-index C++ projects after upgrading. - In C++, a call whose receiver's type can't be read no longer links to a method of a class nested in the class the receiver's name suggests, or of a class whose name only contains that name. Before, such a guess took any method of the call's name whose qualified name contained the class's name: protocolbuffers/protobuf's `descriptor->file()` in a template reached `Descriptor::ExtensionRange::file`, and facebook/rocksdb's `CompositeEnv::IsInstanceOf(name)` reached `CompositeEnvWrapper::IsInstanceOf`. A call written through a class, like `PessimisticTransaction::PopSavePoint()` in an override, now links to the method that class inherits, here `TransactionBaseImpl::PopSavePoint`. Re-index C++ projects after upgrading. - In C++, an alias that names a class through a class derived from the one that declares it, like protocolbuffers/protobuf's `using ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;`, now leads to that class, `ConformanceTestSuite::ConformanceRequestSetting`. Before, an alias was followed only to a class declared under exactly the name it spells, so objects created through this one linked to the alias rather than the class. Re-index C++ projects after upgrading. +- In C++, a name written in a googletest `TEST_F` or `TEST_P` body now finds the test fixture's nested types, those of its base classes included, before anything in the surrounding namespace, as C++ does for the member function such a body is: facebook/rocksdb's `new TestUserDefinedIndexFactory()` links to the class nested in the fixture's base. A class declared inside a test body, or inside any function, is now only found from that function, so the local classes many RocksDB tests each declare under one name, like `FakeWF` or `MyManager`, no longer stand in for one another, and a call like `fwf->SetIOError(true)` reaches the `FakeWF` its own test declares. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-gtest-fixture-scope.test.ts b/__tests__/cpp-gtest-fixture-scope.test.ts new file mode 100644 index 0000000000..147af281fb --- /dev/null +++ b/__tests__/cpp-gtest-fixture-scope.test.ts @@ -0,0 +1,182 @@ +/** + * A gtest `TEST_F(Fixture, Name) { … }` or `TEST_P` body is a member function + * of a class derived from its fixture, so an unqualified name in it finds the + * body's own declarations, then the fixture's members, its bases' included, + * then the enclosing namespaces'. C++ lookup from the body started at the + * function and went straight to the namespaces, so facebook/rocksdb's + * `new TestUserDefinedIndexFactory()`, a class nested in the fixture's base, + * was found only as the one class of that name the file could see (0.7), and + * a namespace-level class of the same name won over the fixture's. + */ +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'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-fixture-scope-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** The `instantiates` edges of every function named `name`, by its first line, as `startLine -> qualifiedName @confidence`. */ +function made(cg: CodeGraph, name: string): string[] { + return cg.getNodesByKind('function').filter((n: Node) => n.name === name).sort((a, b) => a.startLine - b.startLine) + .flatMap((fn) => cg.getOutgoingEdgesFrom([fn.id], ['instantiates']) + .map((e) => `${fn.startLine} -> ${cg.getNode(e.target)?.qualifiedName} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`)); +} + +describe('C++ names in a gtest TEST_F or TEST_P body', () => { + it("find the fixture's nested types, its bases' included", async () => { + const cg = await indexed({ + 'table/table_test.cc': lines( + 'namespace rocksdb {', + 'class UserDefinedIndexTestBase : public testing::Test {', + ' public:', + ' class TestUserDefinedIndexFactory {', + ' public:', + ' TestUserDefinedIndexFactory() {}', + ' };', + '};', + 'class UserDefinedIndexTest : public UserDefinedIndexTestBase {};', + 'TEST_F(UserDefinedIndexTest, Basic) {', + ' auto factory = new TestUserDefinedIndexFactory();', + '}', + 'TEST_P(UserDefinedIndexTest, Params) {', + ' auto factory = new TestUserDefinedIndexFactory();', + '}', + '} // namespace rocksdb', + ), + }); + try { + const nested = 'rocksdb::UserDefinedIndexTestBase::TestUserDefinedIndexFactory @0.9'; + expect(made(cg, 'TEST_F')).toEqual([`10 -> ${nested}`]); + expect(made(cg, 'TEST_P')).toEqual([`13 -> ${nested}`]); + } finally { + cg.close(); + } + }); + + it("find the fixture's type before a namespace's, and the body's own before both", async () => { + const cg = await indexed({ + 'db/db_test.cc': lines( + 'namespace rocksdb {', + 'class Listener {', + ' public:', + ' explicit Listener(int n) {}', + '};', + 'class DBTest : public testing::Test {', + ' public:', + ' class Listener {', + ' public:', + ' explicit Listener(int n) {}', + ' };', + '};', + 'TEST_F(DBTest, Fixture) {', + ' Listener listener(1);', + '}', + 'TEST_F(DBTest, Local) {', + ' class Listener {', + ' public:', + ' explicit Listener(int n) {}', + ' };', + ' Listener listener(2);', + '}', + 'TEST(DBPlainTest, NoFixture) {', + ' Listener listener(3);', + '}', + 'void Helper() { Listener listener(4); }', + '} // namespace rocksdb', + ), + }); + try { + expect(made(cg, 'TEST_F')).toEqual(['13 -> rocksdb::DBTest::Listener @0.9', '16 -> rocksdb::TEST_F::Listener @0.9']); + expect(made(cg, 'TEST')).toEqual(['23 -> rocksdb::Listener @0.9']); + expect(made(cg, 'Helper')).toEqual(['26 -> rocksdb::Listener @0.9']); + } finally { + cg.close(); + } + }); + + it("call the methods of the body's own local class, not another body's", async () => { + const cg = await indexed({ + 'db/listener_test.cc': lines( + 'namespace rocksdb {', + 'class ListenerTest : public testing::Test {};', + 'TEST_F(ListenerTest, First) {', + ' class Listener {', + ' public:', + ' void Fire() {}', + ' };', + ' Listener listener;', + ' listener.Fire();', + '}', + 'TEST_F(ListenerTest, Second) {', + ' class Listener {', + ' public:', + ' void Fire() {}', + ' };', + ' Listener listener;', + ' listener.Fire();', + '}', + '} // namespace rocksdb', + ), + }); + try { + const fired = cg.getNodesByKind('function').filter((n: Node) => n.name === 'TEST_F').sort((a, b) => a.startLine - b.startLine) + .map((fn) => cg.getOutgoingEdgesFrom([fn.id], ['calls']) + .map((e) => `${fn.startLine} -> ${cg.getNode(e.target)?.qualifiedName}:${cg.getNode(e.target)?.startLine} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`)); + expect(fired).toEqual([['3 -> rocksdb::TEST_F::Listener::Fire:6 @0.9'], ['11 -> rocksdb::TEST_F::Listener::Fire:14 @0.9']]); + } finally { + cg.close(); + } + }); + + it("find the body's own local class from a method of that class", async () => { + // rocksdb's env_test.cc: two bodies each declare a `CB`, and a static + // method of the second's calls `cb->Run()` on a `CB*`. + const cg = await indexed({ + 'env/env_test.cc': lines( + 'namespace rocksdb {', + 'class EnvTest : public testing::Test {};', + 'TEST_F(EnvTest, RunMany) {', + ' struct CB {', + ' void Run() {}', + ' };', + '}', + 'TEST_F(EnvTest, TwoPools) {', + ' class CB {', + ' public:', + ' static void Trampoline(void* v) {', + ' CB* cb = static_cast(v);', + ' cb->Run();', + ' }', + ' void Run() {}', + ' };', + '}', + '} // namespace rocksdb', + ), + }); + try { + const from = cg.getNodesByKind('method').find((n: Node) => n.name === 'Trampoline')!; + const calls = cg.getOutgoingEdgesFrom([from.id], ['calls']) + .map((e) => `${cg.getNode(e.target)?.qualifiedName}:${cg.getNode(e.target)?.startLine} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`); + expect(calls).toEqual(['rocksdb::TEST_F::CB::Run:15 @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/cpp-supertypes.ts b/src/resolution/cpp-supertypes.ts index 2ba4bfa000..e3ec8e4702 100644 --- a/src/resolution/cpp-supertypes.ts +++ b/src/resolution/cpp-supertypes.ts @@ -75,6 +75,42 @@ function isCFamily(n: Node): boolean { return n.language === 'cpp' || n.language === 'c'; } +/** + * The declarations code at `site` can see (visibleFrom), where a class local + * to a function counts only in that function's lines: the index names it + * after the function, and every gtest `TEST_F` body in a namespace is + * `ns::TEST_F`, so one body's local `Listener` passed for any other's — in + * the body itself, and in the methods of a local class of its own. + */ +function seenFrom(site: CppNameSite, context: ResolutionContext): (n: Node) => boolean { + const visible = visibleFrom(site.file); + return (n) => { + if (!visible(n)) return false; + const fn = cppFunctionAround(n, context); + return !fn || (fn.filePath === site.file && site.line >= fn.startLine && site.line <= fn.endLine); + }; +} + +const LOCAL_TO = new WeakMap>(); + +/** + * The function a C or C++ class is local to: the one of the scope its + * qualified name is in whose lines hold it. Null for a class of a namespace + * or of another class. + */ +function cppFunctionAround(cls: Node, context: ResolutionContext): Node | null { + let memo = LOCAL_TO.get(context); + if (!memo) LOCAL_TO.set(context, (memo = new Map())); + const hit = memo.get(cls.id); + if (hit !== undefined) return hit; + const scope = cppParentScope(cls.qualifiedName); + const fn = (scope ? context.getNodesByQualifiedName(scope) : []).find((f) => + (f.kind === 'function' || f.kind === 'method') && f.filePath === cls.filePath && + f.startLine <= cls.startLine && f.endLine >= cls.startLine) ?? null; + memo.set(cls.id, fn); + return fn; +} + /** The declarations code in `file` can see: a header's, and the file's own. */ function visibleFrom(file: string): (n: Node) => boolean { return (n) => n.filePath === file || !SOURCE_FILE.test(n.filePath); @@ -110,6 +146,13 @@ export interface CppNameSite { parameters: ReadonlySet; /** A class the name cannot be: the deriving class, in its own base clause. */ self?: Node; + /** + * A class looked in right after `scope` itself, its bases included: a gtest + * `TEST_F` body is a member function of a class derived from its fixture, + * so the fixture's nested types are what its unqualified names find + * before the enclosing namespaces'. + */ + classScope?: string; } /** The class a C or C++ type name names. */ @@ -133,8 +176,9 @@ export function cppClassWritten(written: string, site: CppNameSite, ref: Unresol const global = written.startsWith('::'); if (!global && site.parameters.has(segments[0]!)) return null; - const visible = visibleFrom(site.file); + const visible = seenFrom(site, context); const scopes = cppScopesWithin(site.scope); + if (site.classScope) scopes.splice(1, 0, site.classScope); const resolved = (node: Node): CppWrittenClass => ({ node, exact: true }); // The scopes around the name short of the global one, or only the global one for `::Base`. @@ -234,7 +278,7 @@ function classNode(named: CppNamedClass, site: CppNameSite, context: ResolutionC .filter((n) => isCFamily(n) && CLASS_KINDS.has(n.kind) && n.id !== site.self?.id); // A lookup that ended on the class itself (its namespace lost) found nothing. if (classes.length === 0) return undefined; - const visible = classes.filter(visibleFrom(site.file)); + const visible = classes.filter(seenFrom(site, context)); return pick(visible.length > 0 ? visible : classes, site.file); } @@ -288,9 +332,10 @@ interface CppUsing { const USINGS = new WeakMap>(); -/** Drop the per-file `using` memo (see ReferenceResolver.clearCaches). */ +/** Drop the per-file `using` memo and the local-class memo (see ReferenceResolver.clearCaches). */ export function clearCppSupertypeMemos(context: ResolutionContext): void { USINGS.delete(context); + LOCAL_TO.delete(context); } const PATH = String.raw`(?:::\s*)?[A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*`; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ec463f6635..28c43980d0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -10163,13 +10163,21 @@ function matchCppHeldTypeCall(raw: string, methodName: string, ref: UnresolvedRe const CPP_CLASS_KINDS: ReadonlySet = new Set(['class', 'struct', 'union']); -/** Where a type the calling function writes is looked up from: the function itself, so a class local to it is found first. */ +/** + * Where a type the calling function writes is looked up from: the function + * itself, so a class local to it is found first, then — in a gtest `TEST_F` + * or `TEST_P` body — the test's fixture (cppTestFixture) and its bases, as + * for the member function of a class derived from it that the body is: + * rocksdb's `new TestUserDefinedIndexFactory()` is the fixture base's own. + */ function cppCallSite(caller: Node, ref: UnresolvedRef, context: ResolutionContext): CppNameSite { + const fixture = cppTestFixture(caller, ref, context); return { scope: caller.kind === 'file' ? '' : caller.qualifiedName, file: ref.filePath, line: ref.line, parameters: cppTemplateParameters(caller, context), + ...(fixture ? { classScope: fixture } : {}), }; } @@ -10333,10 +10341,12 @@ function cppBasesKnown(cls: Node, context: ResolutionContext, depth = 0, seen = * call's arguments fit (cppOverloadFor) — else the nearest one of a class * it derives from, through the base edges every declaration of `cls` has. * When other classes share `cls`'s qualified name, its own are the ones - * defined where it is: leveldb's db_bench.cc and db_bench_sqlite3.cc each - * declare a `RandomGenerator`, with a `Generate` that takes a `size_t` and - * an `int`. (A chained call `inner(…).name(…)` keeps the first: the - * arguments at its position are the inner call's.) + * defined in its body, else where it is: the local classes of gtest bodies + * in one namespace are each `ns::TEST_F::Listener`, and leveldb's + * db_bench.cc and db_bench_sqlite3.cc each declare a `RandomGenerator`, with + * a `Generate` that takes a `size_t` and an `int`. (A chained call + * `inner(…).name(…)` keeps the first: the arguments at its position are the + * inner call's.) */ function cppMethodOf(cls: Node, name: string, ref: UnresolvedRef, context: ResolutionContext, depth = 0, seen = new Set()): Node | null { const want = `${cls.qualifiedName}::${name}`; @@ -10346,7 +10356,11 @@ function cppMethodOf(cls: Node, name: string, ref: UnresolvedRef, context: Resol if (own.length > 0) { const ordered = preferCallSiteFile(cppVisibleFirst(own, ref, context), ref.filePath); if (ref.referenceName.includes('().')) return ordered[0]!; - const defined = cppClassDecls(cls.qualifiedName, context).length > 1 + const namesakes = cppClassDecls(cls.qualifiedName, context).length > 1; + const inBody = namesakes + ? ordered.filter((n) => n.filePath === cls.filePath && n.startLine >= cls.startLine && n.endLine <= cls.endLine) + : []; + const defined = inBody.length > 0 ? inBody : namesakes ? ordered.filter((n) => n.filePath === cls.filePath || cppIncluders(cls.filePath, context).has(n.filePath)) : ordered; return cppOverloadFor(defined.length > 0 ? defined : ordered, name, ref, context); From d4b9c2b4e98e600f72e321ba22aa3d50cc2f803f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 06:31:57 +0000 Subject: [PATCH 11/29] fix(cpp): a line starting with `*` is read as code, and block comments and string literals are blanked before declarations are read (#2552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line-at-a-time C++ declaration scans took any line starting with `*` for a comment continuation, skipping rocksdb's `const auto& el =` / `*reinterpret_cast*>(jhandle);`, and read code commented out in `/* … */` and the text of string literals (protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`) as declarations. Block comments are now found in the per-file pass that finds raw strings, and every scan blanks them, with string contents, before reading a line. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-block-comment-lines.test.ts | 209 ++++++++++++++++++++++ src/resolution/cpp-raw-strings.ts | 18 +- src/resolution/name-matcher.ts | 72 ++++---- 4 files changed, 254 insertions(+), 46 deletions(-) create mode 100644 __tests__/cpp-block-comment-lines.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f694b4f073..0f5b7917cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call whose receiver's type can't be read no longer links to a method of a class nested in the class the receiver's name suggests, or of a class whose name only contains that name. Before, such a guess took any method of the call's name whose qualified name contained the class's name: protocolbuffers/protobuf's `descriptor->file()` in a template reached `Descriptor::ExtensionRange::file`, and facebook/rocksdb's `CompositeEnv::IsInstanceOf(name)` reached `CompositeEnvWrapper::IsInstanceOf`. A call written through a class, like `PessimisticTransaction::PopSavePoint()` in an override, now links to the method that class inherits, here `TransactionBaseImpl::PopSavePoint`. Re-index C++ projects after upgrading. - In C++, an alias that names a class through a class derived from the one that declares it, like protocolbuffers/protobuf's `using ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;`, now leads to that class, `ConformanceTestSuite::ConformanceRequestSetting`. Before, an alias was followed only to a class declared under exactly the name it spells, so objects created through this one linked to the alias rather than the class. Re-index C++ projects after upgrading. - In C++, a name written in a googletest `TEST_F` or `TEST_P` body now finds the test fixture's nested types, those of its base classes included, before anything in the surrounding namespace, as C++ does for the member function such a body is: facebook/rocksdb's `new TestUserDefinedIndexFactory()` links to the class nested in the fixture's base. A class declared inside a test body, or inside any function, is now only found from that function, so the local classes many RocksDB tests each declare under one name, like `FakeWF` or `MyManager`, no longer stand in for one another, and a call like `fwf->SetIOError(true)` reaches the `FakeWF` its own test declares. Re-index C++ projects after upgrading. +- In C++, a declaration continued on a line that starts with `*`, like facebook/rocksdb's `const auto& el =` over `*reinterpret_cast*>(jhandle);`, is now read, so calls such as `el->OnFlushCompleted(…)` link to `EventListener`'s methods; such a line used to be skipped as part of a comment. Code commented out in a `/* … */` block, and the text of a string literal, like protocolbuffers/protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`, are no longer read as declarations, which had sent a call to the wrong method or to none. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-block-comment-lines.test.ts b/__tests__/cpp-block-comment-lines.test.ts new file mode 100644 index 0000000000..fde985067e --- /dev/null +++ b/__tests__/cpp-block-comment-lines.test.ts @@ -0,0 +1,209 @@ +/** + * The C++ declaration scans read source a line at a time and took any line + * starting with `*` for the middle of a block comment, and any other line + * for code. So a declaration continued onto a line starting with `*` was + * missed — `Widget` above `*widget_;` — and code commented out inside + * `/* … *\/` without leading stars was read as a declaration. Block comments + * are now found by reading the file once, as raw strings are, and blanked, + * with the contents of string literals, in every scan that reads lines. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-block-comments-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What a function's or method's calls reach, as `qualifiedName @confidence`. */ +function callees(cg: CodeGraph, qualifiedName: string): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .map((e) => `${cg.getNode(e.target)?.qualifiedName} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +/** The same, with how each call was resolved. */ +function resolutions(cg: CodeGraph, qualifiedName: string): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .map((e) => { + const meta = e.metadata as { confidence?: number; resolvedBy?: string } | undefined; + return `${cg.getNode(e.target)?.qualifiedName} @${meta?.confidence} ${meta?.resolvedBy}`; + }) + .sort(); +} + +const SHAPES = lines( + 'class Widget {', + ' public:', + ' void Draw() {}', + '};', + 'class Canvas {', + ' public:', + ' void Draw() {}', + '};', +); + +describe('C++ declarations around block comments', () => { + it('reads a declaration continued on a line that starts with `*`', async () => { + const cg = await indexed({ + 'shapes.h': SHAPES, + 'painter.h': lines( + '#include "shapes.h"', + 'class Painter {', + ' public:', + ' void Paint() { widget_->Draw(); }', + ' private:', + ' Widget', + ' *widget_;', + '};', + ), + }); + try { + expect(callees(cg, 'Painter::Paint')).toEqual(['Widget::Draw @0.9']); + } finally { + cg.close(); + } + }); + + it('reads no declaration from code commented out in a block comment', async () => { + const cg = await indexed({ + 'shapes.h': SHAPES, + 'run.cc': lines( + '#include "shapes.h"', + 'void Run(Canvas* c) {', + ' /*', + ' Widget* c = nullptr;', + ' */', + ' c->Draw();', + '}', + 'void Doc(Canvas* w) {', + ' /*', + ' * Widget* w = nullptr;', + ' */', + ' w->Draw();', + '}', + ), + }); + try { + expect(callees(cg, 'Run')).toEqual(['Canvas::Draw @0.9']); + expect(callees(cg, 'Doc')).toEqual(['Canvas::Draw @0.9']); + } finally { + cg.close(); + } + }); + + it("reads no member, base, using-declaration or local from a class's or function's comments", async () => { + const cg = await indexed({ + 'others.h': lines( + 'class HolderBase {', + ' public:', + ' void GetMessage() {}', + '};', + 'class Old {', + ' public:', + ' void OnlyOld() {}', + '};', + 'class Base {};', + 'class Other {};', + 'template class TBase {', + ' public:', + ' void Insert(int x) {}', + '};', + ), + 'holders.h': lines( + '#include "others.h"', + 'class Holder : public HolderBase {', + ' public:', + ' /*', + ' LOCAL_VAR_ACCESSOR(Message*, Message);', + ' */', + ' void Run() { GetMessage(); }', + '};', + 'class Derived : public Base,', + ' /*', + ' public Old,', + ' */', + ' public Other {', + '};', + 'template class TDerived : public TBase {', + ' public:', + ' /*', + ' using TBase::Insert;', + ' */', + ' void Add() { Insert(1); }', + '};', + 'class Db {', + ' public:', + ' void Flush() {}', + ' void Run() {', + ' /*', + ' auto Flush = [] {};', + ' */', + ' Flush();', + ' }', + '};', + 'void UseDerived(Derived* d) { d->OnlyOld(); }', + ), + }); + try { + // No macro of `Holder`'s might declare `GetMessage`: C++ lookup goes + // on to the base's. + expect(resolutions(cg, 'Holder::Run')).toEqual(['HolderBase::GetMessage @0.9 instance-method']); + // `Derived`'s bases are `Base` and `Other`, the two the index links, + // neither with an `OnlyOld`: the call is not guessed onto `Old`'s. + expect(resolutions(cg, 'UseDerived')).toEqual([]); + // No using-declaration brings the dependent base's `Insert` in: only + // its name, which nothing else declares, links it. + expect(resolutions(cg, 'TDerived::Add')).toEqual(['TBase::Insert @0.9 exact-match']); + // `Flush` is no local: C++ lookup finds the member. + expect(resolutions(cg, 'Db::Run')).toEqual(['Db::Flush @0.9 instance-method']); + } finally { + cg.close(); + } + }); + + it('reads no local from a string literal', async () => { + // protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]` and + // rocksdb's `"Unknown code(%d): "` read as declarations of a local. + const cg = await indexed({ + 'status.h': lines( + 'class Status {', + ' public:', + ' int code() const { return 0; }', + ' [[deprecated("Please use code() instead")]] int Code() const { return code(); }', + ' void ToString() const;', + '};', + ), + 'status.cc': lines( + '#include "status.h"', + 'void Status::ToString() const {', + ' char tmp[30];', + ' snprintf(tmp, sizeof(tmp), "Unknown code(%d): ", code());', + '}', + ), + }); + try { + expect(resolutions(cg, 'Status::Code')).toEqual(['Status::code @0.9 instance-method']); + expect(resolutions(cg, 'Status::ToString')).toEqual(['Status::code @0.9 instance-method']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/cpp-raw-strings.ts b/src/resolution/cpp-raw-strings.ts index 3eaf1b4998..a951b6243e 100644 --- a/src/resolution/cpp-raw-strings.ts +++ b/src/resolution/cpp-raw-strings.ts @@ -10,11 +10,13 @@ * found by reading the file once, as the compiler does: comments, ordinary * string and character literals, and raw strings with their delimiters. * An ordinary string can't span lines, so its contents are blanked on the - * line being read. + * line being read. Block comments are found in the same pass and blanked + * too: a line inside one is no code whatever it starts with, and a line + * outside one is code even when it starts with `*` (`*out = value;`). */ import type { ResolutionContext } from './types'; -/** Column ranges, `[start, end)`, a raw string literal takes on a line (its quotes and delimiters included). */ +/** Column ranges, `[start, end)`, a raw string literal or a block comment takes on a line (its quotes, delimiters and comment markers included). */ type Ranges = Array<[number, number]>; const RAW_STRING_LINES = new WeakMap>>(); @@ -27,17 +29,17 @@ export function clearCppRawStringMemos(context: ResolutionContext): void { const isWordChar = (c: string | undefined): boolean => c !== undefined && /[A-Za-z0-9_$]/.test(c); /** - * The 1-based lines of a C or C++ file that raw string literals cover, each - * with the columns they take. Empty for a file with none. + * The 1-based lines of a C or C++ file that raw string literals or block + * comments cover, each with the columns they take. Empty for a file with none. */ -export function cppRawStringLines(file: string, context: ResolutionContext): Map { +export function cppNonCodeLines(file: string, context: ResolutionContext): Map { let memo = RAW_STRING_LINES.get(context); if (!memo) RAW_STRING_LINES.set(context, (memo = new Map())); const hit = memo.get(file); if (hit) return hit; const lines = new Map(); const text = context.readFile(file); - if (text && text.includes('R"')) { + if (text && (text.includes('R"') || text.includes('/*'))) { let line = 1; let column = 0; const n = text.length; @@ -74,7 +76,9 @@ export function cppRawStringLines(file: string, context: ResolutionContext): Map advance(end < 0 ? n : end); } else if (c === '/' && next === '*') { const end = text.indexOf('*/', i + 2); - advance(end < 0 ? n : end + 2); + const to = end < 0 ? n : end + 2; + mark(i, line, column, to); + advance(to); } else if (c === '"') { // `R"`, `u8R"`, `uR"`, `UR"`, `LR"`, not part of a longer name. const prefix = /(?:u8|[uUL])?R$/.exec(text.slice(Math.max(0, i - 3), i)); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 28c43980d0..fccea2444f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -16,7 +16,7 @@ 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, cppTypeSegments, isCppPointerType, resolveCppAliasedType, stripCppTemplateArguments, type CppAliasedType } from './cpp-type-aliases'; import { clearCppNamespaceMemos, cppMacroNamespaceFrames, cppMacroNamespaceHeads, cppNamespaceAliases } from './cpp-namespaces'; -import { clearCppRawStringMemos, cppLiteralsBlanked, cppRawStringLines } from './cpp-raw-strings'; +import { clearCppRawStringMemos, cppLiteralsBlanked, cppNonCodeLines } from './cpp-raw-strings'; import { cppClassWritten, type CppNameSite } from './cpp-supertypes'; import { cppIncludedFile, cppIncluders } from './cpp-includers'; import { isTestPath } from '../search/query-utils'; @@ -9433,7 +9433,7 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): const declarator = buildDeclaratorRegex(escaped); const end = Math.min(decl.endLine ?? decl.startLine, lines.length); const nested = cppNestedSpans(decl, end, context); - const rawLines = cppRawStringLines(decl.filePath, context); + const rawLines = cppNonCodeLines(decl.filePath, context); const depths = cppClassParenDepths(decl, lines, end, rawLines, context); for (let l = decl.startLine; l <= end; l++) { if (!word.test(lines[l - 1]!) || nested.some(([start, stop]) => l >= start && l <= stop)) continue; @@ -9455,7 +9455,6 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): } if (!m || code!.slice(m.index + m[0].length).startsWith('(')) continue; let depth = depths[from - decl.startLine]!; - if (depth < 0) continue; for (const c of code!.slice(from === decl.startLine ? decl.startColumn : 0, m.index)) { if (c === '(') depth++; else if (c === ')') depth--; @@ -9473,16 +9472,15 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): /** * How many parentheses are open where each line of the C++ class - * declaration `decl`, which ends on line `end`, begins, or -1 for one that - * begins inside a block comment. Comments are followed across lines, not - * guessed as cppCodeOf does: it takes a code line that starts with a - * dereference, `*first_key, scratch);`, for one. + * declaration `decl`, which ends on line `end`, begins. Its block comments + * and literals are blanked first (cppNonCodeLines): a parenthesis in one + * opens nothing. */ function cppClassParenDepths( decl: Node, lines: readonly string[], end: number, - rawLines: ReturnType, + rawLines: ReturnType, context: ResolutionContext, ): number[] { let memo = CPP_CLASS_PAREN_DEPTHS.get(context); @@ -9491,27 +9489,14 @@ function cppClassParenDepths( if (!depths) { depths = []; let depth = 0; - let comment = false; for (let l = decl.startLine; l <= end; l++) { - depths.push(comment ? -1 : depth); + depths.push(depth); const text = cppLiteralsBlanked(lines[l - 1] ?? '', l, rawLines); for (let i = l === decl.startLine ? decl.startColumn : 0; i < text.length; i++) { const c = text[i]!; - if (comment) { - if (c === '*' && text[i + 1] === '/') { - comment = false; - i++; - } - } else if (c === '/' && text[i + 1] === '/') { - break; - } else if (c === '/' && text[i + 1] === '*') { - comment = true; - i++; - } else if (c === '(') { - depth++; - } else if (c === ')' && depth > 0) { - depth--; - } + if (c === '/' && text[i + 1] === '/') break; + else if (c === '(') depth++; + else if (c === ')' && depth > 0) depth--; } } memo.set(decl.id, depths); @@ -10794,9 +10779,11 @@ function cppMemberMacroArguments(decl: Node, context: ResolutionContext): string if (hit) return hit; const words: string[] = []; const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + const nonCode = cppNonCodeLines(decl.filePath, context); let depth = 0; for (let i = decl.startLine - 1; i < Math.min(lines.length, decl.endLine); i++) { - const code = cppCodeOf(i === decl.startLine - 1 ? lines[i]!.slice(decl.startColumn) : lines[i]!) ?? ''; + const blanked = cppLiteralsBlanked(lines[i]!, i + 1, nonCode); + const code = cppCodeOf(i === decl.startLine - 1 ? blanked.slice(decl.startColumn) : blanked) ?? ''; const call = depth === 1 ? /^\s*[A-Z][A-Z0-9_]*\s*\(([^;{}]*)\)\s*;?\s*$/.exec(code) : null; if (call) words.push(...call[1]!.split(/\W+/).filter((w) => w.length >= 3)); for (const c of code) { @@ -10822,7 +10809,8 @@ function cppBaseSpecifiers(decl: Node, context: ResolutionContext): string[] { if (hit) return hit; const specs: string[] = []; const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/); - const head = lines?.slice(decl.startLine - 1, decl.startLine + 11) ?? []; + const nonCode = cppNonCodeLines(decl.filePath, context); + const head = (lines?.slice(decl.startLine - 1, decl.startLine + 11) ?? []).map((l, k) => cppLiteralsBlanked(l, decl.startLine + k, nonCode)); if (head.length > 0) { head[0] = head[0]!.slice(decl.startColumn); const text = head.map((l) => cppCodeOf(l) ?? '').join('\n'); @@ -10853,7 +10841,9 @@ function cppUsingDeclares(decl: Node, base: Node, name: string, context: Resolut if (!usings) { usings = []; const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; - const body = lines.slice(decl.startLine - 1, decl.endLine ?? decl.startLine).map((l) => cppCodeOf(l) ?? '').join('\n'); + const nonCode = cppNonCodeLines(decl.filePath, context); + const body = lines.slice(decl.startLine - 1, decl.endLine ?? decl.startLine) + .map((l, k) => cppCodeOf(cppLiteralsBlanked(l, decl.startLine + k, nonCode)) ?? '').join('\n'); for (const m of body.matchAll(/\busing\s+(?!namespace\b)((?:::\s*)?[A-Za-z_][\w:]*(?:\s*<[^;{}]*>)?)\s*::\s*([A-Za-z_]\w*)\s*;/g)) { usings.push([cppLastSegment(stripCppTemplateArguments(m[1]!).trim()), m[2]!]); } @@ -10889,9 +10879,10 @@ function isCppLocalName(name: string, caller: Node, ref: UnresolvedRef, context: const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const word = new RegExp(`\\b${escaped}\\b`); const declarator = buildDeclaratorRegex(escaped); + const nonCode = cppNonCodeLines(caller.filePath, context); for (let i = Math.max(0, caller.startLine - 1); i < Math.min(lines.length, ref.line); i++) { if (!word.test(lines[i]!)) continue; - const code = cppCodeOf(lines[i]!); + const code = cppCodeOf(cppLiteralsBlanked(lines[i]!, i + 1, nonCode)); if (!code || !word.test(code)) continue; if (cppRebindsReceiver(code, escaped)) return true; // `std::function done;` reads as a declaration once its template arguments are gone. @@ -10905,13 +10896,16 @@ function isCppLocalName(name: string, caller: Node, ref: UnresolvedRef, context: } /** - * A C++ source line as code, for reading declarations: null for a line of a - * comment (`// …`, ` * …`), else the line with its comments blanked, columns - * kept. Receiver inference read leveldb's `// … non-null imm_` as a - * declaration of `imm_` with the type `null`. + * A C++ source line as code, for reading declarations: null for a `// …` + * line, else the line with its comments blanked, columns kept. Receiver + * inference read leveldb's `// … non-null imm_` as a declaration of `imm_` + * with the type `null`. A line inside a block comment is blank by the time + * it gets here (cppLiteralsBlanked, from cppNonCodeLines): a line starting + * with `*` is not one by that alone, as `*widget_;` continuing a declaration + * is not. */ function cppCodeOf(line: string): string | null { - if (/^\s*(?:\/\/|\*)/.test(line)) return null; + if (/^\s*\/\//.test(line)) return null; let code = line.replace(/\/\*.*?\*\//g, (c) => ' '.repeat(c.length)); // A block comment opening here runs past the line. const open = code.indexOf('/*'); @@ -10969,7 +10963,7 @@ function inferCppReceiverType( // A declaration in a string literal is text: protobuf's upb generator // writes `upb_MiniTableField field = $field_init$;` in the raw string it // emits, above calls on its own `upb::FieldDefPtr field` parameter. - const rawLines = cppRawStringLines(ref.filePath, context); + const rawLines = cppNonCodeLines(ref.filePath, context); // Past the calling function's first line, C++ looks the name up among the // members of the function's class before anything outside it. const caller = context.getNodeById?.(ref.fromNodeId); @@ -11072,7 +11066,7 @@ function inferCppReceiverType( ? context.getFileLines(headerPath) : (context.readFile(headerPath)?.split(/\r?\n/) ?? null); if (!headerLines) continue; - const headerRawLines = cppRawStringLines(headerPath, context); + const headerRawLines = cppNonCodeLines(headerPath, context); for (let i = 0; i < headerLines.length; i++) { const text = headerLines[i]!; @@ -11242,7 +11236,7 @@ function cppAutoInitializerStatement( lines: readonly string[], index: number, receiverName: string, - rawLines: ReturnType, + rawLines: ReturnType, ): string | null { const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const start = new RegExp(`\\b${escaped}\\b\\s*=(?!=)`).exec(line); @@ -11257,7 +11251,7 @@ function cppStatementFrom( text: string, lines: readonly string[], index: number, - rawLines: ReturnType, + rawLines: ReturnType, ): string | null { for (let next = index + 1; ; next++) { const end = cppStatementEnd(text); @@ -11289,7 +11283,7 @@ function cppStructuredBindingType( callLineIndex: number, ref: UnresolvedRef, context: ResolutionContext, - rawLines: ReturnType, + rawLines: ReturnType, ): CppWrittenType | null { if (!line.includes('[')) return null; for (const m of line.matchAll(CPP_STRUCTURED_BINDING)) { From 242862182bf4460a1551118d86fe5f47581ad5a4 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 06:32:23 +0000 Subject: [PATCH 12/29] fix(cpp): a comparison or shift in a call's arguments opens no template, and a declared type's template arguments pair their brackets (#2553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting a call's arguments counted every `<` as a template's, so protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` and `WriteVarint(num << 3, s)` read as one argument and took the wrong overloads. A `<` now opens template arguments only right after a name and when a `>` closes it before the bracket around it does, and the splitter closes the list at exactly that `>`. A declared type's template arguments now pair their brackets and may be empty, so `const std::vector& a, std::vector& b` and fmt's `parse_context<>& ctx` are read. A line that ends a declaration begun above, like `std::unique_ptr>>& reqs`, still declares the receiver there. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...mparisons-and-template-declarators.test.ts | 171 ++++++++++++++++++ src/resolution/name-matcher.ts | 65 ++++++- 3 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 __tests__/cpp-comparisons-and-template-declarators.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5b7917cb..b3fac18095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, an alias that names a class through a class derived from the one that declares it, like protocolbuffers/protobuf's `using ConformanceRequestSetting = BinaryAndJsonConformanceSuite::ConformanceRequestSetting;`, now leads to that class, `ConformanceTestSuite::ConformanceRequestSetting`. Before, an alias was followed only to a class declared under exactly the name it spells, so objects created through this one linked to the alias rather than the class. Re-index C++ projects after upgrading. - In C++, a name written in a googletest `TEST_F` or `TEST_P` body now finds the test fixture's nested types, those of its base classes included, before anything in the surrounding namespace, as C++ does for the member function such a body is: facebook/rocksdb's `new TestUserDefinedIndexFactory()` links to the class nested in the fixture's base. A class declared inside a test body, or inside any function, is now only found from that function, so the local classes many RocksDB tests each declare under one name, like `FakeWF` or `MyManager`, no longer stand in for one another, and a call like `fwf->SetIOError(true)` reaches the `FakeWF` its own test declares. Re-index C++ projects after upgrading. - In C++, a declaration continued on a line that starts with `*`, like facebook/rocksdb's `const auto& el =` over `*reinterpret_cast*>(jhandle);`, is now read, so calls such as `el->OnFlushCompleted(…)` link to `EventListener`'s methods; such a line used to be skipped as part of a comment. Code commented out in a `/* … */` block, and the text of a string literal, like protocolbuffers/protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`, are no longer read as declarations, which had sent a call to the wrong method or to none. Re-index C++ projects after upgrading. +- In C++, a call with a comparison or a shift among its arguments, like protocolbuffers/protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` or `WriteVarint(num << 3, s)`, is now read with the right number of arguments, so it links to the overload that fits rather than one taking fewer. A parameter or variable whose type has template arguments is now read even after another such parameter on the same line, like facebook/rocksdb's `const autovector& cf_ids`, or with empty ones, like fmt's `parse_context<>& ctx`: calls on it link to that class's methods, and calls on a `std::vector` declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-comparisons-and-template-declarators.test.ts b/__tests__/cpp-comparisons-and-template-declarators.test.ts new file mode 100644 index 0000000000..ebe6fc77c9 --- /dev/null +++ b/__tests__/cpp-comparisons-and-template-declarators.test.ts @@ -0,0 +1,171 @@ +/** + * Two ways C++ text was misread around `<`: + * - A call's arguments were split at commas outside brackets, counting every + * `<` as a template's. protocolbuffers/protobuf's `p->Emit({{"declare", + * has_bit_word_index < 0 ? … : ""}, …}, R"cc(…)cc")` read as one argument, + * so the overload without substitutions was taken. A `<` now opens a + * template's arguments only right after a name and when a `>` closes it. + * - A declaration's template arguments were matched as any run up to a `>`, + * so on a line declaring `const std::vector& a, std::unique_ptr b` + * the type read for `b` began at `a`'s, and calls on `b` got no type; and + * as one character at least, so fmt's `parse_context<>& ctx` was no type. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-angles-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What the calls at `line` of the function or method `qualifiedName` reach, as `target (its first line) @confidence`. */ +function callsAt(cg: CodeGraph, qualifiedName: string, line: number): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .filter((e) => e.line === line) + .map((e) => { + const t = cg.getNode(e.target)!; + return `${t.qualifiedName}:${t.startLine} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`; + }) + .sort(); +} + +describe('C++ text around `<`', () => { + it('splits a call whose argument compares or shifts at its real commas', async () => { + const cg = await indexed({ + 'printer.h': lines( + 'class Printer {', + ' public:', + ' void Emit(const char* text) {}', + ' void Emit(int vars, const char* text) {}', + ' void Emit(int a, int b, const char* text) {}', + ' void Run(int x, int y, Printer* p) {', + ' Emit(x < 0 ? 1 : 2, "text");', + ' Emit(Size(), "text");', + ' Emit("text");', + ' Emit(Pick(x > 0, 1), "text");', + ' Emit(x < 0, y > 0 ? 1 : 2, "text");', + ' Emit(1<<20, y>>1, "text");', + ' Emit(x<=0, y > 0, "text");', + " Emit(x<0 ? 1 : 2, y ? 3 : '>', \"text\");", + ' Emit(x 0), "text");', + ' Emit(Pick(x<1), y > 0, "text");', + ' Emit(xn, "text");', + ' }', + ' template static int Size() { return 0; }', + ' static int Pick(bool b, int v = 0) { return v; }', + ' int n = 0;', + '};', + ), + }); + try { + const emits = (line: number): string[] => callsAt(cg, 'Printer::Run', line).filter((t) => t.startsWith('Printer::Emit')); + // A comparison, then template arguments with a comma in them. + expect(emits(7)).toEqual(['Printer::Emit:4 @0.9']); + expect(emits(8)).toEqual(['Printer::Emit:4 @0.9']); + expect(emits(9)).toEqual(['Printer::Emit:3 @0.9']); + // A `>` closes no parenthesis. + expect(emits(10)).toEqual(['Printer::Emit:4 @0.9']); + // No template's `<`: after no name, a shift, a `<=`. + expect(emits(11)).toEqual(['Printer::Emit:5 @0.9']); + expect(emits(12)).toEqual(['Printer::Emit:5 @0.9']); + expect(emits(13)).toEqual(['Printer::Emit:5 @0.9']); + // Nor one closed only by a `>` in a literal, in brackets, after the + // bracket around it closes, or in a `->`. + expect(emits(14)).toEqual(['Printer::Emit:5 @0.9']); + expect(emits(15)).toEqual(['Printer::Emit:5 @0.9']); + expect(emits(16)).toEqual(['Printer::Emit:5 @0.9']); + expect(emits(17)).toEqual(['Printer::Emit:5 @0.9']); + } finally { + cg.close(); + } + }); + + it("reads a parameter's type from its own template arguments, none or nested ones included", async () => { + const cg = await indexed({ + 'run.cc': lines( + '#include ', + '#include ', + 'class Foo {', + ' public:', + ' void Go() {}', + '};', + 'class Bar {', + ' public:', + ' void Go() {}', + '};', + 'void Run(const std::vector& a, std::unique_ptr b) {', + ' b->Go();', + '}', + 'template class Context {', + ' public:', + ' void Begin() {}', + '};', + 'template class Outer {', + ' public:', + ' void Go() {}', + '};', + 'template class Inner {};', + 'void Parse(Context<>& ctx) { ctx.Begin(); }', + 'void Deep(std::unique_ptr>> p) { p->Go(); }', + ), + }); + try { + expect(callsAt(cg, 'Run', 12)).toEqual(['Bar::Go:9 @0.9']); + expect(callsAt(cg, 'Parse', 23)).toEqual(['Context::Begin:16 @0.9']); + expect(callsAt(cg, 'Deep', 24)).toEqual(['Outer::Go:20 @0.9']); + } finally { + cg.close(); + } + }); + + it('reads a `->` or a `>>` before the receiver as no declaration begun on a line above', async () => { + // (A templated type whose `>`s outnumber its `<`s is one: see + // cpp-nested-template-receiver.test.ts.) + const cg = await indexed({ + 'painter.h': lines( + 'class Widget {', + ' public:', + ' void Draw() {}', + '};', + 'class Gadget {', + ' public:', + ' void Draw() {}', + '};', + 'class Stream {};', + 'Stream& operator>>(Stream& in, Widget& w);', + 'void Use(Widget* w);', + 'class Painter {', + ' public:', + ' void Run(Stream& in) {', + ' Use(&this->w_);', + ' in >> w_;', + ' w_.Draw();', + ' }', + ' private:', + ' Widget w_;', + '};', + ), + }); + try { + expect(callsAt(cg, 'Painter::Run', 17)).toEqual(['Widget::Draw:3 @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index fccea2444f..e2f0b73307 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9079,6 +9079,19 @@ function cppTypeBeganAbove(typeName: string): boolean { return false; } +/** + * Does `line` end a declaration of the receiver begun on an earlier line, a + * templated type whose `>`s outnumber its `<`s before the name: rocksdb's + * `std::unique_ptr>>& blob_reqs` under `autovectorblob_reqs` and `in >> blob_reqs` + * declare nothing.) + */ +function cppEndsDeclarationBegunAbove(line: string, escapedReceiver: string): boolean { + const end = new RegExp(String.raw`(?:^|[^\w:])([A-Za-z_][\w:]*\s*<[^;=(){}]+>)(?:\s*(?:const\b|volatile\b|[*&]+))*\s*${escapedReceiver}\b`).exec(line); + return end !== null && cppTypeBeganAbove(end[1]!); +} + /** * The last name of a declared C++ type: `const std::vector>&` → `vector`, `ns::Table>` → `Table`. Null when the text @@ -9122,8 +9135,16 @@ const CPP_DECLARATOR_ANNOTATION = String.raw`\b(?:absl_nonnull|absl_nullable|abs * rocksdb's `std::function create_iterator`. */ const CPP_FUNCTION_WRAPPER_TYPE = String.raw`(?:::\s*)?(?:std\s*::\s*(?:function|move_only_function|copyable_function)|absl\s*::\s*(?:FunctionRef|AnyInvocable))\s*<[^;={}]*>`; +/** + * A template argument list, nested up to three deep: `>>`. Its `<`s and `>`s pair up, so on a line + * declaring `const std::vector& a, const std::vector& b` the list + * read for `b` is ``: matching any run up to a `>` read `a`'s parameter + * into `b`'s type. + */ +const CPP_TEMPLATE_ARGUMENTS = String.raw`<(?:[^<>;=(){}]|<(?:[^<>;=(){}]|<[^<>;=(){}]*>)*>)*>`; /** A declared type as the declarator regex reads it, what it carries included: `const FieldDescriptor* PROTOBUF_NULLABLE*`. */ -const CPP_DECLARATOR_TYPE = String.raw`(?:${CPP_FUNCTION_WRAPPER_TYPE}|[A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?)(?:\s*(?:${CPP_DECLARATOR_CV}|[*&]+(?:\s*${CPP_DECLARATOR_ANNOTATION})*))*`; +const CPP_DECLARATOR_TYPE = String.raw`(?:${CPP_FUNCTION_WRAPPER_TYPE}|[A-Za-z_][\w:]*(?:\s*${CPP_TEMPLATE_ARGUMENTS})?)(?:\s*(?:${CPP_DECLARATOR_CV}|[*&]+(?:\s*${CPP_DECLARATOR_ANNOTATION})*))*`; /** A line that is a declared type and nothing else, what may come before a type included: `const std::map&`. */ const CPP_TYPE_LINE = new RegExp(String.raw`^\s*(?:(?:const|volatile|static|mutable|constexpr|inline)\s+)*${CPP_DECLARATOR_TYPE}\s*$`); /** An annotation macro's call after a declared name: leveldb's `VersionSet* const versions_ GUARDED_BY(mutex_);`. */ @@ -11037,6 +11058,9 @@ function inferCppReceiverType( return cppDeclaredType(element.text, elementType, true, ref, context, found); } } + // The end of a declaration begun on an earlier line, maybe the + // receiver's: one found further up may be another variable. + if (cppEndsDeclarationBegunAbove(line, escapedReceiver)) found.shadowed = true; // `for (auto& [cfh, iter] : cfh_iter_pairs_)`, `auto [status, iter] = Open();`. const bound = !found.shadowed ? cppStructuredBindingType(line, lines, i, receiverName, callLineIndex, ref, context, rawLines) : null; const boundType = bound && normalizeCppTypeName(bound.text); @@ -16196,6 +16220,8 @@ function matchCppMacroNamespaced(ref: UnresolvedRef, context: ResolutionContext) function splitCppTopLevel(text: string): string[] { const out: string[] = []; let depth = 0; + // Where each template argument list open around the current character closes. + const closes: number[] = []; let cur = ''; for (let i = 0; i < text.length; i++) { const ch = text[i]!; @@ -16206,15 +16232,46 @@ function splitCppTopLevel(text: string): string[] { i = j; continue; } - if (ch === '(' || ch === '[' || ch === '{' || ch === '<') depth++; - else if (ch === ')' || ch === ']' || ch === '}' || (ch === '>' && text[i - 1] !== '-')) depth = Math.max(0, depth - 1); - if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1); + else if (ch === '<') { + const end = cppTemplateArgumentsEnd(text, i); + if (end > i) closes.push(end); + } else if (ch === '>' && closes[closes.length - 1] === i) closes.pop(); + if (ch === ',' && depth === 0 && closes.length === 0) { out.push(cur.trim()); cur = ''; continue; } cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; } +/** + * Where the template argument list the `<` at `at` opens closes: the index + * of its `>`, or -1 when the `<` compares or shifts. A template's `<` is + * written right after a name, and its `>` comes before the bracket around it + * closes: protobuf's `{"declare", has_bit_word_index < 0 ? … : ""}` was read + * as one argument, the `<` holding the rest open. + */ +function cppTemplateArgumentsEnd(text: string, at: number): number { + if (!/\w/.test(text[at - 1] ?? '') || text[at + 1] === '<' || text[at + 1] === '=') return -1; + let angles = 0; + let others = 0; + for (let i = at; i < text.length; i++) { + const ch = text[i]!; + if (ch === '"' || ch === "'") { + let j = i + 1; + while (j < text.length && text[j] !== ch) j += text[j] === '\\' ? 2 : 1; + i = j; + } else if (ch === '(' || ch === '[' || ch === '{') others++; + else if (ch === ')' || ch === ']' || ch === '}') { + if (--others < 0) return -1; + } else if (others > 0) continue; + else if (ch === '<') angles++; + else if (ch === '>' && text[i - 1] !== '-' && --angles === 0) return i; + } + return -1; +} + /** The balanced `( … )` after `name` from `line`/`column` of `file` (up to a dozen lines), or null. */ function cppParenListAfter(file: string, line: number, column: number, name: string, context: ResolutionContext): string | null { const lines = context.getFileLines?.(file) ?? context.readFile(file)?.split(/\r?\n/) ?? []; From 8cba7e5370213e823f40817a26d3b5c0e7311a9c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 07:33:27 +0000 Subject: [PATCH 13/29] fix(cpp): a nullptr argument picks the std::nullptr_t overload, and no call links to a deleted overload (#2554) A `nullptr` argument fitted any pointer parameter better than a `std::nullptr_t` one, which counted as a class, so protobuf's `ptr.reset(nullptr)` on an `Arena::UniquePtr` took `reset(T*)` over `reset(std::nullptr_t)`. That `reset(T*)` is declared `= delete`, as is `RepeatedPtrField::Add(const Element&)`, which `field.Add("foo")` reached. `nullptr` now fits `std::nullptr_t` exactly and a pointer by conversion, and an overload declared deleted is taken only when every overload is. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-nullptr-deleted-overloads.test.ts | 131 ++++++++++++++++++ src/resolution/name-matcher.ts | 54 ++++++-- 3 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 __tests__/cpp-nullptr-deleted-overloads.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fac18095..b72616c1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a name written in a googletest `TEST_F` or `TEST_P` body now finds the test fixture's nested types, those of its base classes included, before anything in the surrounding namespace, as C++ does for the member function such a body is: facebook/rocksdb's `new TestUserDefinedIndexFactory()` links to the class nested in the fixture's base. A class declared inside a test body, or inside any function, is now only found from that function, so the local classes many RocksDB tests each declare under one name, like `FakeWF` or `MyManager`, no longer stand in for one another, and a call like `fwf->SetIOError(true)` reaches the `FakeWF` its own test declares. Re-index C++ projects after upgrading. - In C++, a declaration continued on a line that starts with `*`, like facebook/rocksdb's `const auto& el =` over `*reinterpret_cast*>(jhandle);`, is now read, so calls such as `el->OnFlushCompleted(…)` link to `EventListener`'s methods; such a line used to be skipped as part of a comment. Code commented out in a `/* … */` block, and the text of a string literal, like protocolbuffers/protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`, are no longer read as declarations, which had sent a call to the wrong method or to none. Re-index C++ projects after upgrading. - In C++, a call with a comparison or a shift among its arguments, like protocolbuffers/protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` or `WriteVarint(num << 3, s)`, is now read with the right number of arguments, so it links to the overload that fits rather than one taking fewer. A parameter or variable whose type has template arguments is now read even after another such parameter on the same line, like facebook/rocksdb's `const autovector& cf_ids`, or with empty ones, like fmt's `parse_context<>& ctx`: calls on it link to that class's methods, and calls on a `std::vector` declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. +- In C++, a call passing `nullptr` now links to the overload that takes a `std::nullptr_t`, as C++ picks it, rather than one taking a pointer, and a call no longer links to an overload declared `= delete`, which no call that compiles can reach: protocolbuffers/protobuf's `ptr.reset(nullptr)` on an `Arena::UniquePtr` reaches `reset(std::nullptr_t)`, and `field.Add("foo")` on a `RepeatedPtrField` reaches `Add(Element&&)` instead of the deleted `Add(const Element&)`. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-nullptr-deleted-overloads.test.ts b/__tests__/cpp-nullptr-deleted-overloads.test.ts new file mode 100644 index 0000000000..7c30d5ad69 --- /dev/null +++ b/__tests__/cpp-nullptr-deleted-overloads.test.ts @@ -0,0 +1,131 @@ +/** + * Two ways a C++ call reached an overload no call that compiles reaches: + * - A `nullptr` argument fitted any pointer parameter as well as it could fit + * anything, and a `std::nullptr_t` parameter no better than a class, so + * protocolbuffers/protobuf's `ptr.reset(nullptr)` on an + * `Arena::UniquePtr` took `reset(T*)` over `reset(std::nullptr_t)`. + * `nullptr` is a `std::nullptr_t` exactly, and a pointer by conversion. + * - That `reset(T*)` is declared `= delete`. A deleted overload is now taken + * only when every overload is. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-nullptr-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What the calls at `line` of the function or method `qualifiedName` reach, as `target:its first line @confidence`. */ +function callsAt(cg: CodeGraph, qualifiedName: string, line: number): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .filter((e) => e.line === line) + .map((e) => { + const t = cg.getNode(e.target)!; + return `${t.qualifiedName}:${t.startLine} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`; + }) + .sort(); +} + +describe('C++ overloads for nullptr, and deleted ones', () => { + it("takes protobuf's reset(std::nullptr_t) for reset(nullptr), not the deleted reset(T*)", async () => { + const cg = await indexed({ + 'arena.h': lines( + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'class Arena {', + ' public:', + ' template ', + ' class UniquePtr {', + ' public:', + ' void reset() {}', + ' void reset(std::nullptr_t) {}', + ' void reset(T* PROTOBUF_NULLABLE) = delete;', + ' };', + '};', + '} // namespace protobuf', + '} // namespace google', + ), + 'arena_unittest.cc': lines( + '#include "arena.h"', + 'namespace google {', + 'namespace protobuf {', + 'void Reset() {', + ' Arena::UniquePtr ptr;', + ' ptr.reset(nullptr);', + ' ptr.reset();', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(callsAt(cg, 'google::protobuf::Reset', 6)).toEqual(['google::protobuf::Arena::UniquePtr::reset:10 @0.9']); + expect(callsAt(cg, 'google::protobuf::Reset', 7)).toEqual(['google::protobuf::Arena::UniquePtr::reset:9 @0.9']); + } finally { + cg.close(); + } + }); + + it('fits nullptr to std::nullptr_t before a pointer, and 0 to an int before both', async () => { + const cg = await indexed({ + 'canvas.h': lines( + '#include ', + 'class Widget {};', + 'class Canvas {', + ' public:', + ' void Set(Widget* w) {}', + ' void Set(std::nullptr_t) {}', + ' void Set(int v) {}', + ' void Run() {', + ' Set(nullptr);', + ' Set(0);', + ' }', + '};', + ), + }); + try { + expect(callsAt(cg, 'Canvas::Run', 9)).toEqual(['Canvas::Set:6 @0.9']); + expect(callsAt(cg, 'Canvas::Run', 10)).toEqual(['Canvas::Set:7 @0.9']); + } finally { + cg.close(); + } + }); + + it('takes no deleted overload while another fits, reading each declaration to its own end', async () => { + const cg = await indexed({ + 'meter.h': lines( + 'class Meter {', + ' public:', + ' void Scale(double factor) = delete;', + ' void Scale(int factor) {}', + ' void Scale(', + ' long factor) = delete;', + ' void Run() { Scale(2); }', + '};', + ), + }); + try { + expect(callsAt(cg, 'Meter::Run', 7)).toEqual(['Meter::Scale:4 @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e2f0b73307..df43979eb3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -10637,9 +10637,13 @@ function cppScopeMethod(scope: CppClassScope, name: string, receiver: 'bare' | ' * (cppOverloadFit), then, among overloads those fit alike, by the types the * arguments have where the call reads them (cppArgumentTypeFit). rocksdb's * `Insert(ch)` on a `char ch` is `Insert(char key, …)`, not `Insert(const - * std::string& key, …)`. + * std::string& key, …)`. A deleted overload (`void reset(T*) = delete;`) is + * none a call that compiles reaches, so it is only taken when every one is. */ -function cppOverloadFor(overloads: Node[], name: string, ref: UnresolvedRef, context: ResolutionContext): Node { +function cppOverloadFor(candidates: Node[], name: string, ref: UnresolvedRef, context: ResolutionContext): Node { + if (candidates.length === 1) return candidates[0]!; + const callable = candidates.filter((n) => !isCppDeletedFunction(n, name, context)); + const overloads = callable.length > 0 ? callable : candidates; if (overloads.length === 1) return overloads[0]!; const args = cppCallArguments(ref, name, context); if (!args) return overloads[0]!; @@ -10661,6 +10665,27 @@ function cppOverloadFor(overloads: Node[], name: string, ref: UnresolvedRef, con return best; } +const CPP_DELETED_FUNCTIONS = new WeakMap>(); + +/** + * Whether the C++ function or method `n`, named `name`, is declared deleted: + * from its name to the declaration's `;` or body, it says `= delete` + * (protobuf's `void reset(T* PROTOBUF_NULLABLE) = delete;`). + */ +function isCppDeletedFunction(n: Node, name: string, context: ResolutionContext): boolean { + let memo = CPP_DELETED_FUNCTIONS.get(context); + if (!memo) CPP_DELETED_FUNCTIONS.set(context, (memo = new Map())); + const hit = memo.get(n.id); + if (hit !== undefined) return hit; + const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? []; + const text = lines.slice(n.startLine - 1, Math.min(lines.length, n.startLine + 11)).join('\n'); + const at = text.search(new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`)); + const declaration = at < 0 ? '' : text.slice(at).split(/[;{]/)[0]!; + const deleted = /=\s*delete\b/.test(declaration); + memo.set(n.id, deleted); + return deleted; +} + /** What a C++ call's argument is: a literal's kind, or the type a name or expression has as written. */ type CppArgumentType = { kind: 'char' | 'number' | 'zero' | 'bool' | 'null' } | { kind: 'type'; text: string }; @@ -10701,7 +10726,7 @@ function cppArgumentType(arg: string, ref: UnresolvedRef, context: ResolutionCon } /** What a C++ type is, for an argument to fit a parameter of it. */ -type CppTypeCategory = 'string' | 'cstring' | 'char' | 'number' | 'bool' | 'pointer' | 'class'; +type CppTypeCategory = 'string' | 'cstring' | 'char' | 'number' | 'bool' | 'pointer' | 'null' | 'class'; const CPP_CHAR_TYPES: ReadonlySet = new Set(['char', 'wchar_t', 'char8_t', 'char16_t', 'char32_t']); const CPP_NUMBER_TYPES: ReadonlySet = new Set([ @@ -10710,12 +10735,16 @@ const CPP_NUMBER_TYPES: ReadonlySet = new Set([ ]); const CPP_STRING_TYPES: ReadonlySet = new Set(['string', 'wstring', 'string_view', 'wstring_view', 'basic_string', 'basic_string_view']); -/** The category of the written C++ type `type`: a pointer's, a string's, a character's, a number's, a class's. */ +/** + * The category of the written C++ type `type`: a pointer's, a string's, a + * character's, a number's, `std::nullptr_t`'s, a class's. + */ function cppTypeCategory(type: string): CppTypeCategory { const flat = stripCppTemplateArguments(type).replace(/\b(?:const|volatile|struct|class|typename|enum)\b/g, ' ').replace(/&+/g, ' ').trim(); const words = flat.replace(/\*/g, ' ').trim().split(/\s+/); const last = cppLastSegment(words[words.length - 1] ?? ''); if (flat.includes('*')) return CPP_CHAR_TYPES.has(last) && (flat.match(/\*/g) ?? []).length === 1 ? 'cstring' : 'pointer'; + if (last === 'nullptr_t') return 'null'; if (last === 'bool') return 'bool'; if (CPP_CHAR_TYPES.has(last) && words.every((w) => CPP_CHAR_TYPES.has(w) || w === 'signed' || w === 'unsigned')) return 'char'; if (CPP_NUMBER_TYPES.has(last) || words.every((w) => CPP_NUMBER_TYPES.has(w))) return 'number'; @@ -10744,13 +10773,17 @@ function cppArgumentTypeFit(n: Node, name: string, types: Array> = { - char: { char: 3, number: 1, bool: 0, string: -3, cstring: -3, pointer: -3, class: 0 }, - number: { number: 2, char: 1, bool: 0, string: -3, cstring: -3, pointer: -3, class: 0 }, - zero: { number: 2, char: 1, bool: 0, pointer: 1, cstring: 1, string: -3, class: 0 }, - bool: { bool: 3, number: 1, char: 0, string: -3, cstring: -3, pointer: -3, class: 0 }, - null: { pointer: 3, cstring: 3, class: 1, string: -3, char: -3, number: -3, bool: -1 }, + char: { char: 3, number: 1, bool: 0, string: -3, cstring: -3, pointer: -3, null: -3, class: 0 }, + number: { number: 2, char: 1, bool: 0, string: -3, cstring: -3, pointer: -3, null: -3, class: 0 }, + zero: { number: 2, char: 1, bool: 0, pointer: 1, cstring: 1, null: 1, string: -3, class: 0 }, + bool: { bool: 3, number: 1, char: 0, string: -3, cstring: -3, pointer: -3, null: -3, class: 0 }, + null: { null: 4, pointer: 3, cstring: 3, class: 1, string: -3, char: -3, number: -3, bool: -1 }, }; /** How an argument written as type `arg` fits a parameter of type `param` (of category `category`). */ @@ -11957,6 +11990,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CPP_USING_MEMBERS.delete(context); CPP_MEMBER_MACRO_ARGS.delete(context); CPP_OVERLOAD_PARAMETERS.delete(context); + CPP_DELETED_FUNCTIONS.delete(context); CPP_LOOP_ELEMENTS.delete(context); CPP_AUTO_LOCALS.delete(context); CPP_SCOPE_NAMES.delete(context); From e03ff6730aada736d288336aaf96c2bdcc7e0079 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 07:33:54 +0000 Subject: [PATCH 14/29] fix(cpp): a receiver declared over more than one line has its type read (#2555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receiver inference read a declaration a line at a time, so a receiver whose type began on a line above (protobuf's `const google::protobuf::internal::ExtensionIdentifier<` over `FeatureSet, TypeTraitsT, field_type, is_packed>& extension`) or sat alone on the line above its name (rocksdb's `autovector<…>` over `cf_sv_pairs;`) declared nothing it could read, and the call fell to name matching. Such a line is now read with the lines it began on: joined until the template brackets pair, or with the type line above, as class members already were. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-nested-template-receiver.test.ts | 7 +- __tests__/cpp-two-line-receivers.test.ts | 164 ++++++++++++++++++ src/resolution/name-matcher.ts | 56 +++++- 4 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 __tests__/cpp-two-line-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b72616c1ce..9a231a5d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a declaration continued on a line that starts with `*`, like facebook/rocksdb's `const auto& el =` over `*reinterpret_cast*>(jhandle);`, is now read, so calls such as `el->OnFlushCompleted(…)` link to `EventListener`'s methods; such a line used to be skipped as part of a comment. Code commented out in a `/* … */` block, and the text of a string literal, like protocolbuffers/protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`, are no longer read as declarations, which had sent a call to the wrong method or to none. Re-index C++ projects after upgrading. - In C++, a call with a comparison or a shift among its arguments, like protocolbuffers/protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` or `WriteVarint(num << 3, s)`, is now read with the right number of arguments, so it links to the overload that fits rather than one taking fewer. A parameter or variable whose type has template arguments is now read even after another such parameter on the same line, like facebook/rocksdb's `const autovector& cf_ids`, or with empty ones, like fmt's `parse_context<>& ctx`: calls on it link to that class's methods, and calls on a `std::vector` declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a call passing `nullptr` now links to the overload that takes a `std::nullptr_t`, as C++ picks it, rather than one taking a pointer, and a call no longer links to an overload declared `= delete`, which no call that compiles can reach: protocolbuffers/protobuf's `ptr.reset(nullptr)` on an `Arena::UniquePtr` reaches `reset(std::nullptr_t)`, and `field.Add("foo")` on a `RepeatedPtrField` reaches `Add(Element&&)` instead of the deleted `Add(const Element&)`. Re-index C++ projects after upgrading. +- In C++, a variable or parameter declared over more than one line, with its type begun on a line above or alone on the line above its name, now has its type read, so calls on it link to that class's methods: protocolbuffers/protobuf's `extension.number()` on a `const google::protobuf::internal::ExtensionIdentifier<` … `>& extension` parameter reaches protobuf's `ExtensionIdentifier`, not hpb's, and facebook/rocksdb's `cf_sv_pairs.size()` on an `autovector<…>` written above `cf_sv_pairs;` reaches `autovector`. Calls on a standard-library container declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-nested-template-receiver.test.ts b/__tests__/cpp-nested-template-receiver.test.ts index b6e3e9ac96..b911c1c424 100644 --- a/__tests__/cpp-nested-template-receiver.test.ts +++ b/__tests__/cpp-nested-template-receiver.test.ts @@ -195,11 +195,12 @@ describe('a C++ receiver declared with nested template arguments', () => { } }); - it('the end of a declaration begun on the line above names no type, not even its template argument\'s', async () => { + it('the end of a declaration begun on the line above is read with that line, not as its template argument\'s type', async () => { // rocksdb's blob_file_reader.h breaks `autovector>>& blob_reqs` over two lines. The second // line alone, read as a type, is a `CachableEntry` here: the receiver's - // type is the `autovector` on the line above. + // type is the `autovector` on the line above, which the two lines read + // together declare. const cg = await indexed({ ...ROCKSDB, 'table/block_based/cachable_entry.h': [ @@ -228,7 +229,7 @@ describe('a C++ receiver declared with nested template arguments', () => { ].join('\n'), }); try { - expect(calls(cg, 'rocksdb::MultiGetBlob')).toEqual([]); + expect(resolvedCalls(cg, 'rocksdb::MultiGetBlob')).toEqual(['rocksdb::autovector::size instance-method@0.9']); } finally { cg.close(); } diff --git a/__tests__/cpp-two-line-receivers.test.ts b/__tests__/cpp-two-line-receivers.test.ts new file mode 100644 index 0000000000..2e302a441c --- /dev/null +++ b/__tests__/cpp-two-line-receivers.test.ts @@ -0,0 +1,164 @@ +/** + * A C++ receiver whose declaration is written over more than one line: its + * type begun on a line above, or alone on the line above its name. Receiver + * inference read a declaration a line at a time, so the line naming the + * receiver declared nothing it could read, and the call fell to name matching: + * protocolbuffers/protobuf's `extension.number()` on a `const + * google::protobuf::internal::ExtensionIdentifier<` / `FeatureSet, + * TypeTraitsT, field_type, is_packed>& extension` reached hpb's + * `ExtensionIdentifier::number`. The lines are now read together. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-two-line-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What a function's or method's calls reach, as `qualifiedName resolvedBy@confidence`. */ +function calls(cg: CodeGraph, qualifiedName: string): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .map((e) => { + const meta = e.metadata as { confidence?: number; resolvedBy?: string } | undefined; + return `${cg.getNode(e.target)?.qualifiedName} ${meta?.resolvedBy}@${meta?.confidence}`; + }) + .sort(); +} + +const TABLES = lines( + '#include ', + '#include ', + 'template ', + 'class Table {', + ' public:', + ' int size() const { return 0; }', + '};', + 'class Index {', + ' public:', + ' int size() const { return 0; }', + '};', +); + +describe('C++ receivers declared over more than one line', () => { + it("reads a type begun on a line above: protobuf's ExtensionIdentifier parameter", async () => { + const cg = await indexed({ + 'src/google/protobuf/extension_set.h': lines( + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'namespace internal {', + 'template ', + 'class ExtensionIdentifier {', + ' public:', + ' int number() const { return 0; }', + '};', + '} // namespace internal', + '} // namespace protobuf', + '} // namespace google', + ), + 'hpb/extension.h': lines( + 'namespace hpb {', + 'namespace internal {', + 'template ', + 'class ExtensionIdentifier {', + ' public:', + ' int number() const { return 0; }', + '};', + '} // namespace internal', + '} // namespace hpb', + ), + 'src/google/protobuf/descriptor.h': lines( + '#include "src/google/protobuf/extension_set.h"', + 'namespace google {', + 'namespace protobuf {', + 'class FeatureSet {};', + 'class DescriptorPool {', + ' public:', + ' template ', + ' bool ResolvesFeaturesFor(', + ' const google::protobuf::internal::ExtensionIdentifier<', + ' FeatureSet, TypeTraitsT, field_type, is_packed>& extension) const {', + ' return extension.number() > 0;', + ' }', + '};', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::DescriptorPool::ResolvesFeaturesFor')) + .toEqual(['google::protobuf::internal::ExtensionIdentifier::number instance-method@0.9']); + } finally { + cg.close(); + } + }); + + it('reads a type whose template arguments run over several lines above', async () => { + const cg = await indexed({ + 'tables.h': TABLES, + 'visit.cc': lines( + '#include "tables.h"', + 'int Visit(', + ' const Table<', + ' std::string,', + ' std::shared_ptr>& table) {', + ' return table.size();', + '}', + ), + }); + try { + expect(calls(cg, 'Visit')).toEqual(['Table::size instance-method@0.9']); + } finally { + cg.close(); + } + }); + + it('reads a type written alone on the line above the name, and no expression continued there', async () => { + const cg = await indexed({ + 'tables.h': TABLES, + 'build.cc': lines( + '#include "tables.h"', + 'struct Meters {', + ' int value() const { return 0; }', + '};', + 'Meters operator*(int k, const Meters& m);', + 'int Build() {', + ' Table>', + ' table;', + ' Table', + ' *table_ptr = nullptr;', + ' return table.size() + table_ptr->size();', + '}', + 'int Scaled(int factor, const Meters& length) {', + ' Meters total = factor', + ' * length;', + ' return length.value() + total.value();', + '}', + ), + }); + try { + expect(calls(cg, 'Build')).toEqual(['Table::size instance-method@0.9', 'Table::size instance-method@0.9']); + // `* length;` continues `Meters total = factor`: `length` is the parameter. + expect(calls(cg, 'Scaled')).toEqual(['Meters::value instance-method@0.9', 'Meters::value instance-method@0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index df43979eb3..09724c5388 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9092,6 +9092,50 @@ function cppEndsDeclarationBegunAbove(line: string, escapedReceiver: string): bo return end !== null && cppTypeBeganAbove(end[1]!); } +/** + * The type a declaration of the receiver on the code `line` (lines[index]) + * declares when its type is written on lines above, read with them: + * - the end of a type begun above, its text before the name closing a `<` + * an earlier line opened — protobuf's `const + * google::protobuf::internal::ExtensionIdentifier<` above `FeatureSet, + * TypeTraitsT, field_type, is_packed>& extension) const {`. Lines are + * joined, up to six, until the brackets pair; + * - a type alone on the line above, the name starting this one — rocksdb's + * `std::unordered_map>` + * above `props;`, as cppDataMemberIn reads a member. + * Null when the line needs no line above, or the lines read together declare + * no type there. + */ +function cppDeclarationBegunAbove( + lines: readonly string[], + index: number, + line: string, + receiverPattern: RegExp, + declaratorRegex: RegExp, + rawLines: ReturnType, +): string | null { + const at = line.search(receiverPattern); + if (at < 0 || index === 0) return null; + const codeAt = (j: number): string => cppCodeOf(cppLiteralsBlanked(lines[j]!, j + 1, rawLines)) ?? ''; + const head = line.slice(0, at); + if (/^\s*[*&]*\s*$/.test(head)) { + const above = codeAt(index - 1); + return CPP_TYPE_LINE.test(above) ? matchCppDeclarator(`${above} ${line}`, declaratorRegex)?.[1] ?? null : null; + } + // (Only such a line can need the lines above: every other line naming the + // receiver is spared joining them.) + if (!cppTypeBeganAbove(head)) return null; + let text = line; + let before = head; + for (let j = index - 1; j >= Math.max(0, index - 6); j--) { + const above = codeAt(j); + text = `${above} ${text}`; + before = `${above} ${before}`; + if (!cppTypeBeganAbove(before)) return matchCppDeclarator(text, declaratorRegex)?.[1] ?? null; + } + return null; +} + /** * The last name of a declared C++ type: `const std::vector>&` → `vector`, `ns::Table>` → `Table`. Null when the text @@ -11091,8 +11135,16 @@ function inferCppReceiverType( return cppDeclaredType(element.text, elementType, true, ref, context, found); } } - // The end of a declaration begun on an earlier line, maybe the - // receiver's: one found further up may be another variable. + // A declaration begun on an earlier line, read with the lines it began on. + const begun = cppDeclarationBegunAbove(lines, i, line, receiverPattern, declaratorRegex, rawLines); + const begunType = begun && normalizeCppTypeName(begun); + if (begun && begunType && begunType !== 'auto') { + const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, ref, context); + noteCppDeclaration(found, begun, inCallerScope); + return cppDeclaredType(begun, begunType, inCallerScope, ref, context, found); + } + // One those lines don't make readable is still maybe the receiver's: + // one found further up may be another variable. if (cppEndsDeclarationBegunAbove(line, escapedReceiver)) found.shadowed = true; // `for (auto& [cfh, iter] : cfh_iter_pairs_)`, `auto [status, iter] = Open();`. const bound = !found.shadowed ? cppStructuredBindingType(line, lines, i, receiverName, callLineIndex, ref, context, rawLines) : null; From 1753472a3f25a43e97bfcadc79fa8bd2d7eb32cc Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 07:59:41 +0000 Subject: [PATCH 15/29] fix(cpp): a call of a member template with explicit template arguments has the type the template returns (#2556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member call was read only when its callee ended in the method's name, so `reflection->GetRaw>(…)` and `factory->GetOptions()` gave the `auto` locals they initialize no type. Such a call now reaches the class's template overloads, and when each is declared to return the same one of its own template parameters (`template const Type& GetRaw(…)`), it returns the call's argument for it. A macro body's `\`-spliced lines are read as one statement. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-auto-local-receiver.test.ts | 5 +- __tests__/cpp-member-template-returns.test.ts | 189 ++++++++++++++++++ src/resolution/name-matcher.ts | 74 ++++++- 4 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 __tests__/cpp-member-template-returns.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a231a5d7c..2c3b4b7947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call with a comparison or a shift among its arguments, like protocolbuffers/protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` or `WriteVarint(num << 3, s)`, is now read with the right number of arguments, so it links to the overload that fits rather than one taking fewer. A parameter or variable whose type has template arguments is now read even after another such parameter on the same line, like facebook/rocksdb's `const autovector& cf_ids`, or with empty ones, like fmt's `parse_context<>& ctx`: calls on it link to that class's methods, and calls on a `std::vector` declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a call passing `nullptr` now links to the overload that takes a `std::nullptr_t`, as C++ picks it, rather than one taking a pointer, and a call no longer links to an overload declared `= delete`, which no call that compiles can reach: protocolbuffers/protobuf's `ptr.reset(nullptr)` on an `Arena::UniquePtr` reaches `reset(std::nullptr_t)`, and `field.Add("foo")` on a `RepeatedPtrField` reaches `Add(Element&&)` instead of the deleted `Add(const Element&)`. Re-index C++ projects after upgrading. - In C++, a variable or parameter declared over more than one line, with its type begun on a line above or alone on the line above its name, now has its type read, so calls on it link to that class's methods: protocolbuffers/protobuf's `extension.number()` on a `const google::protobuf::internal::ExtensionIdentifier<` … `>& extension` parameter reaches protobuf's `ExtensionIdentifier`, not hpb's, and facebook/rocksdb's `cf_sv_pairs.size()` on an `autovector<…>` written above `cf_sv_pairs;` reaches `autovector`. Calls on a standard-library container declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. +- In C++, a variable initialized from a call of a member function template with explicit template arguments now has the type that template returns for them, so calls on it link to that class's methods: protocolbuffers/protobuf's `const auto& rep = reflection->GetRaw>(…)` is a `RepeatedPtrField`, and facebook/rocksdb's `auto bbto = factory->GetOptions()` a `BlockBasedTableOptions`. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-auto-local-receiver.test.ts b/__tests__/cpp-auto-local-receiver.test.ts index c179d179b8..c7aa667064 100644 --- a/__tests__/cpp-auto-local-receiver.test.ts +++ b/__tests__/cpp-auto-local-receiver.test.ts @@ -515,8 +515,9 @@ describe('what keeps the call as it was', () => { ), }); try { - // Not the shared_ptr member's type, which has no SetName. - expect(calls(cg, 'rocksdb::Rename').filter((c) => c.includes('SetName'))).toEqual(['rocksdb::test::ChanglingMergeOperator::SetName @0.7']); + // Not the shared_ptr member's type, which has no SetName: + // the type `CheckedCast()` returns. + expect(calls(cg, 'rocksdb::Rename').filter((c) => c.includes('SetName'))).toEqual(['rocksdb::test::ChanglingMergeOperator::SetName @0.9']); } finally { cg.close(); } diff --git a/__tests__/cpp-member-template-returns.test.ts b/__tests__/cpp-member-template-returns.test.ts new file mode 100644 index 0000000000..73b08a72e3 --- /dev/null +++ b/__tests__/cpp-member-template-returns.test.ts @@ -0,0 +1,189 @@ +/** + * An `auto` local initialized from a call of a C++ member template with + * explicit template arguments has the type the template returns for them. + * A member call was read only when its callee ended in the method's name, so + * `reflection->GetRaw>(…)` was no call it could + * read; and a return type that is the template's own parameter (`template + * const Type& GetRaw(…)`) is what the arguments make it. + * protocolbuffers/protobuf's `rep.size()` on such a local, in a macro whose + * lines end in `\`, guessed `SooRep::size`; facebook/rocksdb's `auto bbto = + * factory->GetOptions();` gave `bbto` no type. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-member-template-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What a function's calls of `method` reach, as `qualifiedName resolvedBy@confidence`. */ +function calls(cg: CodeGraph, qualifiedName: string, method: string): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .map((e) => { + const meta = e.metadata as { confidence?: number; resolvedBy?: string } | undefined; + return `${cg.getNode(e.target)?.qualifiedName} ${meta?.resolvedBy}@${meta?.confidence}`; + }) + .filter((t) => t.split(' ')[0]!.endsWith(`::${method}`)) + .sort(); +} + +const SHAPES = lines( + 'class Widget {', + ' public:', + ' void Draw() {}', + '};', + 'class Gadget {', + ' public:', + ' void Draw() {}', + '};', +); + +describe('C++ member templates called with explicit template arguments', () => { + it("types protobuf's `const auto& rep =` from GetRaw>, its line spliced with `\\`", async () => { + const cg = await indexed({ + 'src/google/protobuf/reflection.h': lines( + 'namespace google {', + 'namespace protobuf {', + 'class Message {};', + 'class FieldDescriptor {};', + 'template ', + 'class RepeatedPtrField {', + ' public:', + ' int size() const { return 0; }', + '};', + 'class SooRep {', + ' public:', + ' int size() const { return 0; }', + '};', + 'class Reflection {', + ' public:', + ' template ', + ' const Type& GetRaw(const Message& message, const FieldDescriptor* field) const;', + '};', + 'template ', + 'const Type& Reflection::GetRaw(const Message& message, const FieldDescriptor* field) const {', + ' static Type value;', + ' return value;', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + 'src/google/protobuf/reflection_visit_fields.h': lines( + '#include "src/google/protobuf/reflection.h"', + 'namespace google {', + 'namespace protobuf {', + 'int VisitFields(const Reflection* reflection, const Message& message,', + ' const FieldDescriptor* field) {', + // protobuf writes this in a macro, whose lines end in `\`. + ' const auto& rep = \\', + ' reflection->GetRaw>(message, field); \\', + ' ;', + ' return rep.size();', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::VisitFields', 'size')).toEqual(['google::protobuf::RepeatedPtrField::size instance-method@0.9']); + } finally { + cg.close(); + } + }); + + it("types rocksdb's `auto bbto =` from GetOptions(), const and not", async () => { + const cg = await indexed({ + 'include/rocksdb/configurable.h': lines( + 'namespace rocksdb {', + 'class Configurable {', + ' public:', + ' template ', + ' const T* GetOptions() const { return nullptr; }', + ' template ', + ' T* GetOptions() { return nullptr; }', + '};', + 'struct BlockBasedTableOptions {', + ' bool IsValid() const { return true; }', + '};', + 'struct PlainTableOptions {', + ' bool IsValid() const { return true; }', + '};', + '} // namespace rocksdb', + ), + 'db/check.cc': lines( + '#include "include/rocksdb/configurable.h"', + 'namespace rocksdb {', + 'bool Check(Configurable* factory) {', + ' auto bbto = factory->GetOptions();', + ' return bbto->IsValid();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Check', 'IsValid')).toEqual(['rocksdb::BlockBasedTableOptions::IsValid instance-method@0.9']); + } finally { + cg.close(); + } + }); + + it('reads a template overload only, a defaulted parameter by its name, and no guess between overloads', async () => { + const cg = await indexed({ + 'shapes.h': SHAPES, + 'store.cc': lines( + '#include "shapes.h"', + 'class Store {', + ' public:', + ' Widget* Find() { return nullptr; }', + ' template ', + ' T* Find() { return nullptr; }', + ' template ', + ' T* Make() { return nullptr; }', + ' template ', + ' A* Get() { return nullptr; }', + ' template ', + ' B* Get(int key) { return nullptr; }', + '};', + 'void Found(Store* store) {', + ' auto found = store->Find();', + ' found->Draw();', + '}', + 'void Made(Store* store) {', + ' auto made = store->Make();', + ' made->Draw();', + '}', + 'void Got(Store* store) {', + ' auto got = store->Get(1);', + ' got->Draw();', + '}', + ), + }); + try { + // Explicit template arguments call `T* Find()`, never `Widget* Find()`. + expect(calls(cg, 'Found', 'Draw')).toEqual(['Gadget::Draw instance-method@0.9']); + expect(calls(cg, 'Made', 'Draw')).toEqual(['Gadget::Draw instance-method@0.9']); + // The overloads return different parameters; which one the call takes + // isn't read, so neither is guessed. + expect(calls(cg, 'Got', 'Draw').filter((t) => t.endsWith('@0.9'))).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 09724c5388..d3c99d0122 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9397,6 +9397,61 @@ function cppWrittenReturnType(fn: Node, context: ResolutionContext): string | nu return written; } +/** + * The names of the template parameters a C++ function template's own + * `template <…>` header declares, in order, without their defaults: + * `['Type']` for protobuf's `template const Type& GetRaw(…)`. + * A template's node starts at its declaration, so the header is the code + * right before it. Empty for a function that is no template. + */ +function cppOwnTemplateParameters(fn: Node, context: ResolutionContext): string[] { + const lines = context.getFileLines?.(fn.filePath) ?? context.readFile(fn.filePath)?.split(/\r?\n/); + if (!lines) return []; + const at = lines[fn.startLine - 1] ?? ''; + const before = [...lines.slice(Math.max(0, fn.startLine - 4), fn.startLine - 1), at.slice(0, fn.startColumn)] + .map((line) => cppCodeOf(line) ?? '').join(' ').trimEnd(); + let depth = 0; + for (let i = before.endsWith('>') ? before.length - 1 : -1; i >= 0; i--) { + if (before[i] === '>') depth++; + else if (before[i] === '<' && --depth === 0) { + if (!/\btemplate\s*$/.test(before.slice(0, i))) return []; + return splitCppTopLevel(before.slice(i + 1, -1)).map((p) => /([A-Za-z_]\w*)$/.exec(p.split('=')[0]!.trim())?.[1] ?? ''); + } + } + return []; +} + +/** + * What a call of the C++ member template whose overloads are `methods`, with + * the explicit template arguments `args` (`<…>` as written), returns when + * every overload is declared to return the same one of its own template + * parameters, perhaps const, a pointer or a reference: that argument, so + * qualified, as written at the call (`site`). protobuf's `template const Type& GetRaw(…)` called as `reflection->GetRaw>(…)` returns a `const RepeatedPtrField&`, and + * rocksdb's `T* GetOptions()` called as `GetOptions()` + * a `BlockBasedTableOptions*`. Undefined otherwise: then the return type is + * read as written. + */ +function cppMemberTemplateReturn( + methods: readonly Node[], + args: string, + site: Omit, + context: ResolutionContext, +): CppWrittenType | undefined { + let slot: { index: number; written: string; param: string } | undefined; + for (const fn of methods) { + const written = cppWrittenReturnType(fn, context); + if (!written) continue; + const param = written.replace(/\b(?:const|volatile)\b|[*&]/g, ' ').trim(); + const index = param ? cppOwnTemplateParameters(fn, context).indexOf(param) : -1; + if (index < 0 || (slot && slot.index !== index)) return undefined; + slot ??= { index, written, param }; + } + const arg = slot && splitCppTopLevel(args.slice(1, -1))[slot.index]; + return slot && arg ? { ...site, text: slot.written.replace(new RegExp(`\\b${slot.param}\\b`), arg) } : undefined; +} + /** What one of `fns` (overloads, a declaration and its definition) returns, as written where it is declared. */ function cppReturnTypeOf(fns: readonly Node[], context: ResolutionContext): CppWrittenType | null { for (const fn of fns) { @@ -9785,12 +9840,17 @@ function cppExpressionType(expr: string, at: UnresolvedRef, context: ResolutionC const returned = wrapper && cppWrapperReturnType(wrapper); if (returned) return returned; } - // `recv.method(…)`, `recv->method(…)`: the method of the receiver's class. - const member = /^(.+?)\s*(\.|->)\s*([A-Za-z_]\w*)$/.exec(callee); + // `recv.method(…)`, `recv->method(…)`, `recv->method(…)`: the method of the receiver's class. + const member = /^(.+?)\s*(\.|->)\s*([A-Za-z_]\w*)\s*(<[\s\S]*>)?$/.exec(callee); if (member) { const object = cppMemberObject(member[1]!, member[2]!, at, context, depth); const cls = object && cppWrittenClass(object, at, context); - return cls ? cppReturnTypeOf(cppMethodsOf(cls, member[3]!, at, context), context) : null; + if (!cls) return null; + const methods = cppMethodsOf(cls, member[3]!, at, context); + if (!member[4]) return cppReturnTypeOf(methods, context); + // Explicit template arguments call a template, never an overload that is none. + const templates = methods.filter((fn) => cppOwnTemplateParameters(fn, context).length > 0); + return cppMemberTemplateReturn(templates, member[4], callers, context) ?? cppReturnTypeOf(templates, context); } if (!/^(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*(?:\s*<[\s\S]*>)?$/.test(callee)) return null; const spelled = stripCppTemplateArguments(callee).replace(/\s+/g, ''); @@ -11354,7 +11414,9 @@ function cppAutoInitializerStatement( /** * The C++ statement that `text`, the rest of `lines[index]`, begins, read on - * to the `;` that ends it. Null when no `;` ends it within a few lines. + * to the `;` that ends it. Null when no `;` ends it within a few lines. A + * macro body's lines end in `\`, which splices them: protobuf's `const auto& + * rep = \` goes on with `reflection->GetRaw<…>(message, field); \`. */ function cppStatementFrom( text: string, @@ -11362,11 +11424,13 @@ function cppStatementFrom( index: number, rawLines: ReturnType, ): string | null { + const spliced = (code: string): string => code.replace(/\\\s*$/, ' '); + text = spliced(text); for (let next = index + 1; ; next++) { const end = cppStatementEnd(text); if (end >= 0) return text.slice(0, end).trim() || null; if (next >= lines.length || next > index + 4) return null; - text += ` ${cppCodeOf(cppLiteralsBlanked(lines[next]!, next + 1, rawLines)) ?? ''}`; + text += ` ${spliced(cppCodeOf(cppLiteralsBlanked(lines[next]!, next + 1, rawLines)) ?? '')}`; } } From dc2289f6212f9a9c92f8f35a7330ccec873ceea0 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 03:11:08 -0600 Subject: [PATCH 16/29] fix(cpp): link no call or type name to what only another source file can see Native port of upstream #2538 (isCppTranslationUnitLocal, cppVisibleFirst): a type a source file declares, a member of a class only source files declare, and what a source file's unnamed namespace declares are seen only by that file, by files that include it, and by headers it includes below the declaration. --- codegraph-kernel/src/resolve/bound.rs | 1 + .../src/resolve/cpp_implicit_this.rs | 5 +- .../src/resolve/cpp_translation_unit.rs | 203 ++++++++++++++++++ codegraph-kernel/src/resolve/mod.rs | 8 + codegraph-kernel/src/resolve/names.rs | 7 +- 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 codegraph-kernel/src/resolve/cpp_translation_unit.rs diff --git a/codegraph-kernel/src/resolve/bound.rs b/codegraph-kernel/src/resolve/bound.rs index 86f4dfb0cf..a49b828ed9 100644 --- a/codegraph-kernel/src/resolve/bound.rs +++ b/codegraph-kernel/src/resolve/bound.rs @@ -1273,6 +1273,7 @@ Ok(self.dart_member_of(type_name, method, r, false)? } return Ok(None); } + let matches = self.cpp_visible_first(matches, r)?; Ok(Self::pick_method_match(matches, r, confidence, resolved_by, preferred_fqn)) } diff --git a/codegraph-kernel/src/resolve/cpp_implicit_this.rs b/codegraph-kernel/src/resolve/cpp_implicit_this.rs index e88091350c..4e9ae4f954 100644 --- a/codegraph-kernel/src/resolve/cpp_implicit_this.rs +++ b/codegraph-kernel/src/resolve/cpp_implicit_this.rs @@ -302,7 +302,10 @@ impl KernelResolver { pub(super) fn cpp_method_of(&mut self, cls: &KNode, name: &str, r: &ResolveRefIn, depth: usize, seen: &mut HashSet) -> Res>> { let own: Vec<_> = self.nodes_by_qualified_name(&format!("{}::{name}", cls.qualified_name))? .iter().filter(|n| n.kind == "method" && c_family(&n.language)).cloned().collect(); - if !own.is_empty() { return Ok(prefer_call_site_file(own, &r.file_path).into_iter().next()); } + if !own.is_empty() { + let own = self.cpp_visible_first(own, r)?; + return Ok(prefer_call_site_file(own, &r.file_path).into_iter().next()); + } seen.insert(cls.qualified_name.clone()); if depth >= 4 || !self.supertypes_complete { return Ok(None); } for decl in self.cpp_class_decls(&cls.qualified_name)? { diff --git a/codegraph-kernel/src/resolve/cpp_translation_unit.rs b/codegraph-kernel/src/resolve/cpp_translation_unit.rs new file mode 100644 index 0000000000..addde4883f --- /dev/null +++ b/codegraph-kernel/src/resolve/cpp_translation_unit.rs @@ -0,0 +1,203 @@ +//! What only another C or C++ translation unit can name: a type or alias a source file declares, +//! a member of a class only source files declare, and what a source file's unnamed namespace +//! (`namespace { … }`) declares. rocksdb's test fixture `ColumnFamilyTestBase` is declared in +//! column_family_test.cc, and other test files' bare `Put(…)` reached its `Put` by name; +//! inlineskiplist.h's `Node*` reached hash_linklist_rep.cc's own `Node`. +//! +//! Port of upstream's `isCppTranslationUnitLocal` and `cppVisibleFirst` (name-matcher.ts at +//! 61cb4188, #2538); read the original with `git show 61cb4188:src/resolution/name-matcher.ts`. +//! What a source file declares is still seen by the file itself, by a file that includes it, +//! directly or through others (a test that includes the source it tests), and by a header it +//! includes below the declaration (rocksdb's version_set.cc includes version_set_sync_and_async.h +//! after its `FilePicker` classes). +use super::tables::c_source_ext_re; +use super::*; + +/// What a C or C++ type declaration can be: the kinds a translation-unit-local type has. +fn cpp_type_kind(kind: &str) -> bool { + matches!(kind, "class" | "struct" | "union" | "enum" | "interface" | "type_alias") +} + +fn cpp_class_kind(kind: &str) -> bool { + matches!(kind, "class" | "struct" | "union") +} + +/// The line spans (1-based, inclusive) of the unnamed namespaces `text` writes, the outermost of +/// nested ones: what one declares has internal linkage, so no other translation unit names it. +/// Comments, preprocessor lines and string, character and raw string literals are skipped. +pub(super) fn unnamed_namespace_spans(text: &str) -> Vec<(i64, i64)> { + let mut spans = Vec::new(); + if !re!(r"\bnamespace\s*\{").is_match(text) { return spans; } + let bytes = text.as_bytes(); + // Each open brace: the line it opens on, and whether it opens an unnamed namespace. + let mut open: Vec<(i64, bool)> = Vec::new(); + let mut line = 1i64; + let mut line_start = true; + // The code since the last brace or `;`, without comments and literals. + let mut code = String::new(); + let mut i = 0; + let count_lines = |from: usize, to: usize| bytes[from..to.min(bytes.len())].iter().filter(|&&b| b == b'\n').count() as i64; + while i < bytes.len() { + let c = bytes[i]; + if c == b'\n' { + line += 1; + line_start = true; + code.push(' '); + i += 1; + continue; + } + if line_start && c == b'#' { + // A preprocessor line, continued by a trailing backslash. + let mut end = i; + while end < bytes.len() && (bytes[end] != b'\n' || (end > 0 && bytes[end - 1] == b'\\') || (end > 1 && bytes[end - 1] == b'\r' && bytes[end - 2] == b'\\')) { + if bytes[end] == b'\n' { line += 1; } + end += 1; + } + i = end; + continue; + } + if !c.is_ascii_whitespace() { line_start = false; } + if c == b'/' && bytes.get(i + 1) == Some(&b'/') { + i = text[i..].find('\n').map_or(bytes.len(), |n| i + n); + continue; + } + if c == b'/' && bytes.get(i + 1) == Some(&b'*') { + let stop = text[i + 2..].find("*/").map_or(bytes.len(), |n| i + 2 + n + 2); + line += count_lines(i, stop); + i = stop; + continue; + } + if c == b'"' && { + let from = (i.saturating_sub(4)..i).find(|&k| text.is_char_boundary(k)).unwrap_or(i); + re!(r"(?:^|[^\w$])(?:u8|[uUL])?R$").is_match(&text[from..i]) + } { + // `R"delim( … )delim"`. + let stop = match text[i..].find('(') { + Some(p) => { + let paren = i + p; + let closing = format!("){}\"", &text[i + 1..paren]); + text[paren..].find(&closing).map_or(bytes.len(), |n| paren + n + closing.len()) + } + None => bytes.len(), + }; + line += count_lines(i, stop); + i = stop; + continue; + } + if c == b'"' || c == b'\'' { + let mut j = i + 1; + while j < bytes.len() && bytes[j] != c && bytes[j] != b'\n' { + j += if bytes[j] == b'\\' { 2 } else { 1 }; + } + i = j + 1; + continue; + } + match c { + b'{' => { + open.push((line, re!(r"(?:^|[^\w$])namespace\s*$").is_match(&code))); + code.clear(); + } + b'}' => { + if let Some((start, unnamed)) = open.pop() { + if unnamed && !open.iter().any(|(_, u)| *u) { spans.push((start, line)); } + } + code.clear(); + } + b';' => code.clear(), + _ => { + // (Only the code's end is read; keep it short.) + if code.len() > 200 { code.drain(..code.len() - 100); } + if c.is_ascii() { code.push(c as char); } else { code.push('_'); } + } + } + i += 1; + } + spans +} + +impl KernelResolver { + /// Whether only another C or C++ translation unit than `r`'s can name `candidate`. + pub(super) fn cpp_translation_unit_local(&mut self, candidate: &KNode, r: &ResolveRefIn) -> Res { + if !matches!(r.language.as_str(), "c" | "cpp") { return Ok(false); } + if cpp_type_kind(&candidate.kind) { return Ok(!self.cpp_seen_from(candidate, &r.file_path)?); } + // A member written in its class's declaration is that declaration's; one defined out of + // line is the class's, wherever a declaration of it its own file sees is (through an + // alias, the class may be declared anywhere). A scope its file sees no type of may be a + // namespace: fmt's posix-mock-test.cc defines `test::close` of posix-mock.h's `namespace + // test`, and ostream-test.cc declares a `struct test` of its own. + if let Some(enclosing) = self.cpp_enclosing_class_decl(candidate)? { + return Ok(!self.cpp_seen_from(&enclosing, &r.file_path)?); + } + let scope = match candidate.qualified_name.rfind("::") { Some(cut) => &candidate.qualified_name[..cut], None => "" }; + if !scope.is_empty() { + let mut decls = Vec::new(); + for d in self.nodes_by_qualified_name(scope)?.iter() { + if cpp_type_kind(&d.kind) && matches!(d.language.as_str(), "c" | "cpp") && self.cpp_seen_from(d, &candidate.file_path)? { decls.push(d.clone()); } + } + if decls.iter().any(|d| d.kind == "type_alias") { return Ok(false); } + if !decls.is_empty() { + for d in &decls { + if self.cpp_seen_from(d, &r.file_path)? { return Ok(false); } + } + return Ok(true); + } + } + if self.cpp_seen_from(candidate, &r.file_path)? { return Ok(false); } + let Some(source) = self.read_file(&candidate.file_path) else { return Ok(false) }; + Ok(source.cpp_unnamed_namespaces().iter().any(|&(start, end)| candidate.start_line >= start && candidate.start_line <= end)) + } + + /// Whether the declaration `decl` is seen from `file`: it is no source file's, or `file` is its + /// own, includes its file, or is a header its file includes below it. + fn cpp_seen_from(&mut self, decl: &KNode, file: &str) -> Res { + if !c_source_ext_re().is_match(&decl.file_path) || decl.file_path == file { return Ok(true); } + if self.namespace_visible_files(file, &decl.language)?.contains(&decl.file_path) { return Ok(true); } + self.cpp_includes_after(&decl.file_path, decl.start_line, file, &decl.language) + } + + /// Whether the C or C++ file `source` `#include`s `header` below line `line`. + fn cpp_includes_after(&mut self, source: &str, line: i64, header: &str, language: &str) -> Res { + let includes: Vec<_> = self.nodes_in_file(source)?.iter() + .filter(|n| n.kind == "import" && n.start_line > line && matches!(n.language.as_str(), "c" | "cpp")).cloned().collect(); + for node in includes { + let delimiter = if node.signature.as_deref().is_some_and(|s| s.contains('<')) { '<' } else { '"' }; + if self.resolve_cpp_include(source, delimiter, &node.name, language)?.as_deref() == Some(header) { return Ok(true); } + } + Ok(false) + } + + /// The innermost C or C++ class, struct or union declaration of its file whose lines hold `member`. + fn cpp_enclosing_class_decl(&mut self, member: &KNode) -> Res>> { + let mut innermost: Option> = None; + for cls in self.nodes_in_file(&member.file_path)?.iter() { + if !cpp_class_kind(&cls.kind) || cls.id == member.id || member.start_line < cls.start_line || member.start_line > cls.end_line { continue; } + if innermost.as_ref().is_none_or(|i| cls.start_line >= i.start_line) { innermost = Some(cls.clone()); } + } + Ok(innermost) + } + + /// Of the methods a C or C++ type's name gives, those `r`'s translation unit can see, when + /// there are any (cppVisibleFirst). Classes of one qualified name can be declared apart: + /// rocksdb's db_stress_shared_state.h and two benchmark `.cc` files each declare a + /// `SharedState`, and a call in db_stress_common.cc is the header's. + pub(super) fn cpp_visible_first(&mut self, methods: Vec>, r: &ResolveRefIn) -> Res>> { + if methods.len() < 2 || !matches!(r.language.as_str(), "c" | "cpp") { return Ok(methods); } + let mut visible = Vec::new(); + for m in &methods { + if !self.cpp_translation_unit_local(m, r)? { visible.push(m.clone()); } + } + Ok(if visible.is_empty() { methods } else { visible }) + } +} + +#[cfg(test)] +mod tests { + use super::unnamed_namespace_spans; + + #[test] + fn unnamed_namespaces_past_comments_and_literals() { + let text = "namespace a {\nnamespace {\nint f();\nnamespace {\n}\n}\n// namespace {\n/* namespace { */\n#define X namespace {\nconst char* s = \"namespace {\";\nconst char* r = R\"(a \"namespace {\" b)\";\n}\n"; + assert_eq!(unnamed_namespace_spans(text), vec![(2, 6)]); + assert_eq!(unnamed_namespace_spans("#define B \\\n namespace {\nint g();\n"), Vec::<(i64, i64)>::new()); + } +} diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index fd41ce35d8..de340c04c5 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -343,6 +343,7 @@ pub(super) struct SourceFile { php_file_scope: OnceCell, rust_code_lines: OnceCell>, cpp_code_lines: OnceCell>, + cpp_unnamed_namespaces: OnceCell>, python_file: OnceCell, python_field_file: OnceCell, cpp_field_tree: OnceCell>>, @@ -362,6 +363,7 @@ impl SourceFile { php_file_scope: OnceCell::new(), rust_code_lines: OnceCell::new(), cpp_code_lines: OnceCell::new(), + cpp_unnamed_namespaces: OnceCell::new(), python_file: OnceCell::new(), python_field_file: OnceCell::new(), cpp_field_tree: OnceCell::new(), @@ -416,6 +418,11 @@ impl SourceFile { .get_or_init(|| fields::mask_rust_code(self.text()).split('\n').map(str::to_string).collect()) } + /// The line spans of the file's unnamed namespaces (cpp_translation_unit). + pub(super) fn cpp_unnamed_namespaces(&self) -> &[(i64, i64)] { + self.cpp_unnamed_namespaces.get_or_init(|| cpp_translation_unit::unnamed_namespace_spans(self.text())) + } + /// C++ lines with raw literals and comments masked, shared by receiver lookups. pub(super) fn cpp_code_lines(&self) -> &[String] { self.cpp_code_lines.get_or_init(|| awaited::strip_ts_comments(&cpp::mask_cpp_raw_strings(self.text())).split('\n').map(str::to_string).collect()) @@ -574,6 +581,7 @@ mod cpp_receivers; mod cpp_range_for; mod cpp_implicit_this; mod cpp_namespace; +mod cpp_translation_unit; mod inherited_method; mod store; mod esm_scope; diff --git a/codegraph-kernel/src/resolve/names.rs b/codegraph-kernel/src/resolve/names.rs index 71d94ea3ac..c62d80002c 100644 --- a/codegraph-kernel/src/resolve/names.rs +++ b/codegraph-kernel/src/resolve/names.rs @@ -311,12 +311,15 @@ impl KernelResolver { || !self.language_type_visible(candidate, r)? { return Ok(false); } let lang = candidate.language.as_str(); if lang == "c" || lang == "cpp" { - return Ok(candidate.kind != "function" + let static_seen = candidate.kind != "function" || !c_source_ext_re().is_match(&candidate.file_path) || !self.is_static_c_function(candidate)? || (matches!(r.language.as_str(), "c" | "cpp") && (self.c_includes(&r.file_path, &candidate.file_path)? - || self.c_sole_includer(candidate, &r.file_path)?))); + || self.c_sole_includer(candidate, &r.file_path)?)); + // So is what a source file's unnamed namespace declares, and a type only source files + // declare, with its members (cpp_translation_unit). + return Ok(static_seen && !self.cpp_translation_unit_local(candidate, r)?); } if lang == "go" { let first = candidate.name.chars().next().unwrap_or('_'); From 1b30d1928f1eab16e20cf6edfac2497c3159506d Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 03:11:08 -0600 Subject: [PATCH 17/29] fix(cpp): a call through a class reaches its own or inherited method only Native port of upstream #2548: `Outer::Count()` is the member C++ looks up in `Outer`, its own or one a base declares, never a nested or namesake class's method of that name. --- codegraph-kernel/src/resolve/method_call.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index bb6cbc48c4..e07b0f291c 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -1354,13 +1354,26 @@ if inferred.is_none() && r.language == "dart" { if !class_like || c.language != r.language { continue; } + // C++'s `Outer::Count()` is the member C++ looks up in `Outer`: its own, else one a + // base declares, never one of a class nested in it, like `Outer::Inner::Count` beside + // an inherited `Base::Count`. + if c.language == "cpp" && resolved_by == "qualified-name" { + let Some(found) = self.cpp_method_of(c, method, r, 0, &mut HashSet::new())? else { continue }; + let own = found.qualified_name == format!("{}::{method}", c.qualified_name); + return Ok(Some(KCand { node: found, confidence: if own { 0.85 } else { 0.8 }, resolved_by })); + } let in_file = self.nodes_in_file(&c.file_path)?; if let Some(mn) = in_file .iter() // An owner segment equal to the class name: `Loud` is not - // `Loudspeaker`. + // `Loudspeaker`. In C and C++, only a method of exactly that + // class: protobuf's `arena.get()` is not `Arena::UniquePtr::get`. .find(|n| { - n.kind == "method" && n.name == method && n.qualified_name.split([':', '.']).any(|s| s == c.name) + n.kind == "method" && n.name == method && if matches!(c.language.as_str(), "c" | "cpp") { + n.qualified_name == format!("{}::{method}", c.qualified_name) + } else { + n.qualified_name.split([':', '.']).any(|s| s == c.name) + } }) { return Ok(Some(KCand { node: mn.clone(), confidence, resolved_by })); From 20bdbed88dedeb2d7447bc9b5a629db66ca47c08 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 03:42:52 -0600 Subject: [PATCH 18/29] fix(cpp): read receivers through annotations, bindings and wrappers, and pick overloads by argument types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports upstream #2536, #2537, #2539, #2540, #2541 and #2554 to the native resolver. They share helpers (the implicit-this lookup, the written-type readers and the overload chooser), so they land together. - #2536: a declarator's type is read past annotation macros (`Printer* PROTOBUF_NONNULL printer_`, `versions_ GUARDED_BY(mutex_)`); the resolver now parses C++ with the same macros blanked that extraction blanks, byte offsets unchanged. - #2537, #2539: a gtest fixture's body looks names up in the fixture class, and a dependent base is searched only where a using-declaration names the member. - #2540: structured bindings, `std::function` results and library map elements give a receiver its type; library holders are read from their template arguments. - #2541: of a class's overloads, a typed receiver's call takes the one its arguments fit, by count and string literals, then by the argument types as written. A chained call `inner(…).name(…)` keeps the first: the arguments at its position are the inner call's. - #2554: `nullptr` fits `std::nullptr_t` before a pointer, and a deleted overload is taken only when every overload is. --- codegraph-kernel/src/resolve/bound.rs | 28 ++ .../src/resolve/cpp_annotations.rs | 208 +++++++++++++++ .../src/resolve/cpp_implicit_this.rs | 77 ++++-- codegraph-kernel/src/resolve/cpp_overloads.rs | 245 ++++++++++++++++++ codegraph-kernel/src/resolve/cpp_range_for.rs | 142 ++++++++-- codegraph-kernel/src/resolve/cpp_receivers.rs | 40 ++- codegraph-kernel/src/resolve/iteration.rs | 4 +- codegraph-kernel/src/resolve/mod.rs | 2 + .../src/resolve/overloads_upstream.rs | 15 +- codegraph-kernel/src/resolve/receivers.rs | 74 +++++- 10 files changed, 783 insertions(+), 52 deletions(-) create mode 100644 codegraph-kernel/src/resolve/cpp_annotations.rs create mode 100644 codegraph-kernel/src/resolve/cpp_overloads.rs diff --git a/codegraph-kernel/src/resolve/bound.rs b/codegraph-kernel/src/resolve/bound.rs index a49b828ed9..5f374acf5b 100644 --- a/codegraph-kernel/src/resolve/bound.rs +++ b/codegraph-kernel/src/resolve/bound.rs @@ -726,6 +726,26 @@ if site.language == "dart" && site.reference_kind == "calls" { }) .cloned() .collect(); + if members.len() > 1 && site.language == "cpp" && site.reference_kind == "calls" && !site.reference_name.contains("().") { + // A C++ call on a typed receiver takes the overload its arguments fit, not the first + // one declared (a chained call `inner(…).name(…)` keeps the first: the arguments at + // its position are the inner call's). + // When other classes share the owner's qualified name, its own overloads are the ones + // defined in its body, else in its file or a file that file is included by (leveldb's + // db_bench.cc and db_bench_sqlite3.cc each declare a `RandomGenerator`). + let ordered = prefer_call_site_file(self.cpp_visible_first(members, site)?, &site.file_path); + let defined: Vec<_> = if self.cpp_class_decls(&owner.qualified_name)?.len() > 1 { + let in_body: Vec<_> = ordered.iter().filter(|n| n.file_path == owner.file_path && n.start_line >= owner.start_line && n.end_line <= owner.end_line).cloned().collect(); + if in_body.is_empty() { + let mut near = Vec::new(); + for n in &ordered { + if n.file_path == owner.file_path || self.namespace_visible_files(&n.file_path, "cpp")?.contains(&owner.file_path) { near.push(n.clone()); } + } + near + } else { in_body } + } else { ordered.clone() }; + return self.cpp_overload_for(if defined.is_empty() { ordered } else { defined }, method, site).map(Some); + } Ok(if members.len() == 1 { members.into_iter().next() } else { @@ -1274,6 +1294,14 @@ Ok(self.dart_member_of(type_name, method, r, false)? return Ok(None); } let matches = self.cpp_visible_first(matches, r)?; + // A C++ call on a typed receiver takes the overload its arguments fit, not the first one + // declared (a chained call `inner(…).name(…)` keeps the first: the arguments at its + // position are the inner call's). + if r.language == "cpp" && r.reference_kind == "calls" && matches.len() > 1 && !r.reference_name.contains("().") { + let ordered = prefer_call_site_file(matches, &r.file_path); + let node = self.cpp_overload_for(ordered, method, r)?; + return Ok(Some(KCand { node, confidence, resolved_by })); + } Ok(Self::pick_method_match(matches, r, confidence, resolved_by, preferred_fqn)) } diff --git a/codegraph-kernel/src/resolve/cpp_annotations.rs b/codegraph-kernel/src/resolve/cpp_annotations.rs new file mode 100644 index 0000000000..4a078e50ae --- /dev/null +++ b/codegraph-kernel/src/resolve/cpp_annotations.rs @@ -0,0 +1,208 @@ +//! The C++ the resolver parses has the annotation macros extraction blanks before parsing blanked +//! the same way, so a declaration reads as extraction read it: protobuf's `Printer* +//! PROTOBUF_NONNULL printer_;` declares `printer_`, and leveldb's `VersionSet* const versions_ +//! GUARDED_BY(mutex_);` declares `versions_`. tree-sitter takes such a macro for the declared name, +//! or for a second declarator, and errors. +//! +//! Port of two of c-cpp.ts's preParse passes, `blankCppPointerAnnotationMacros` and +//! `blankCppDeclaratorAttributeMacros`, matched on the code with comments and literals masked. +//! Each macro is replaced by spaces, so every byte offset stays where it was. +use std::borrow::Cow; +use std::rc::Rc; +use std::sync::LazyLock; + +use regex::Regex; + +/// `text` with comments and the contents of string and character literals replaced by spaces, +/// byte for byte (line breaks kept). +fn masked_code(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut out = bytes.to_vec(); + let blank = |out: &mut Vec, from: usize, to: usize| { + for b in &mut out[from..to.min(bytes.len())] { if *b != b'\n' && *b != b'\r' { *b = b' '; } } + }; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + let end = text[i..].find('\n').map_or(bytes.len(), |n| i + n); + blank(&mut out, i, end); + i = end; + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + let end = text[i + 2..].find("*/").map_or(bytes.len(), |n| i + 2 + n + 2); + blank(&mut out, i, end); + i = end; + } + b'R' if bytes.get(i + 1) == Some(&b'"') && (i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_') || matches!(bytes[i - 1], b'8' | b'u' | b'U' | b'L')) => { + let open = i + 1; + let Some(paren) = text[open..].find('(').map(|p| open + p) else { i += 1; continue }; + let closing = format!("){}\"", &text[open + 1..paren]); + let end = text[paren..].find(&closing).map_or(bytes.len(), |n| paren + n + closing.len()); + blank(&mut out, open, end); + i = end; + } + quote @ (b'"' | b'\'') => { + let mut j = i + 1; + while j < bytes.len() && bytes[j] != quote && bytes[j] != b'\n' { + j += if bytes[j] == b'\\' { 2 } else { 1 }; + } + blank(&mut out, i + 1, j.min(bytes.len())); + i = j + 1; + } + _ => i += 1, + } + } + out +} + +fn is_word(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' } + +/// The end of the ALL-CAPS annotation macro name (`[A-Z][A-Z0-9]*_[A-Z0-9_]*`) starting at `at`. +fn macro_name_end(code: &[u8], at: usize) -> Option { + if !code.get(at)?.is_ascii_uppercase() || (at > 0 && is_word(code[at - 1])) { return None; } + let mut end = at; + while end < code.len() && is_word(code[end]) { end += 1; } + let name = &code[at..end]; + (name.contains(&b'_') && name.iter().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_')).then_some(end) +} + +fn skip_space(code: &[u8], mut i: usize) -> usize { + while i < code.len() && code[i].is_ascii_whitespace() { i += 1; } + i +} + +/// The end of the balanced argument list opening at `open`, three levels deep at most. +fn arguments_end(code: &[u8], open: usize) -> Option { + let mut depth = 0; + for (i, &b) in code.iter().enumerate().skip(open) { + match b { + b'(' => { depth += 1; if depth > 3 { return None; } } + b')' => { depth -= 1; if depth == 0 { return Some(i + 1); } } + b';' | b'{' | b'}' => return None, + _ => {} + } + } + None +} + +const ALTERNATIVE_OPERATORS: &[&str] = &["and", "and_eq", "bitand", "bitor", "compl", "not", "not_eq", "or", "or_eq", "xor", "xor_eq"]; +const NOT_A_TYPE: &[&str] = &["return", "throw", "co_return", "case", "goto", "else", "do", "new", "delete", "sizeof", "typedef", "using"]; + +/// The spans of the macros right after a `*` or `&` and before the name it declares (or another +/// `*`): `Arena* PROTOBUF_NULLABLE arena`, `const FieldDescriptor* PROTOBUF_NULLABLE* PROTOBUF_NONNULL +/// field`. Not after a `)`, `]` or number (a product), in a `return` or an assignment's value, or +/// when nothing follows (`Foo* PROTOBUF_NONNULL)` names the parameter). +fn pointer_annotation_spans(code: &[u8], text: &str, spans: &mut Vec<(usize, usize)>) { + for (star, &b) in code.iter().enumerate() { + if b != b'*' && b != b'&' { continue; } + let mut at = skip_space(code, star + 1); + let mut run = Vec::new(); + while let Some(end) = macro_name_end(code, at) { + run.push((at, end)); + let next = skip_space(code, end); + at = next; + if next == end && code.get(end) != Some(&b'*') { break; } + } + if run.is_empty() { continue; } + let after = at; + let follows = match code.get(after) { + Some(b'*') => b == b'*', + Some(b':') => code.get(after + 1) == Some(&b':'), + Some(c) if c.is_ascii_alphabetic() || *c == b'_' || *c == b'~' => { + let mut end = after; + while end < code.len() && is_word(code[end]) { end += 1; } + !ALTERNATIVE_OPERATORS.contains(&&text[after..end]) + } + _ => false, + }; + if !follows { continue; } + let before = String::from_utf8_lossy(&code[star.saturating_sub(40)..star]); + if re!(r"(?:[)\]]|\b\d[\w.]*)\s*$").is_match(&before) { continue; } + let boundary = code[..star].iter().rposition(|c| matches!(c, b';' | b'{' | b'}')).map_or(0, |p| p + 1); + let prefix = String::from_utf8_lossy(&code[boundary..star]); + if re!(r"\b(?:return|throw|co_return)\b").is_match(&prefix) || re!(r"(?:^|[^=!<>])=(?:[^=]|$)").is_match(&prefix) { continue; } + spans.extend(run); + } +} + +/// The spans of the macros after the name a declaration declares, or after its array brackets: +/// `versions_ GUARDED_BY(mutex_);`, `cord ABSL_ATTRIBUTE_LIFETIME_BOUND)`. The macro is followed by +/// `;`, `=`, `,` or `)`, and stands after `]` or after a name with a lowercase letter that itself +/// follows a type (a name, `*`, `&` or `>`), so a variable named in capitals stays. +fn declarator_attribute_spans(code: &[u8], text: &str, spans: &mut Vec<(usize, usize)>) { + let mut i = 0; + while i < code.len() { + let Some(mut end) = macro_name_end(code, i) else { i += 1; continue }; + let start = i; + let mut run = Vec::new(); + let mut at = start; + while let Some(name_end) = macro_name_end(code, at) { + end = name_end; + let open = skip_space(code, end); + if code.get(open) == Some(&b'(') { + match arguments_end(code, open) { Some(close) => end = close, None => break } + } + run.push((at, end)); + at = skip_space(code, end); + } + i = end.max(start + 1); + if run.is_empty() || !matches!(code.get(at), Some(b';' | b'=' | b',' | b')')) || code.get(at + 1) == Some(&b'=') && code[at] == b'=' { continue; } + // What stands before the macro. + let mut k = start; + let space_end = k; + while k > 0 && code[k - 1].is_ascii_whitespace() { k -= 1; } + if k == space_end || k == 0 { continue; } + let declares = if code[k - 1] == b']' { true } else { + let name_end = k; + while k > 0 && is_word(code[k - 1]) { k -= 1; } + let name = &text[k..name_end]; + if name.is_empty() || !name.bytes().any(|b| b.is_ascii_lowercase()) || name.as_bytes()[0].is_ascii_digit() { false } else { + let mut t = k; + while t > 0 && code[t - 1].is_ascii_whitespace() { t -= 1; } + match t.checked_sub(1).map(|p| code[p]) { + Some(b'*' | b'&' | b'>') => true, + Some(c) if is_word(c) => { + let word_end = t; + while t > 0 && is_word(code[t - 1]) { t -= 1; } + !NOT_A_TYPE.contains(&&text[t..word_end]) + } + _ => false, + } + } + }; + if declares { spans.extend(run); } + } +} + +/// C++ `text` with the annotation macros extraction blanks before parsing replaced by spaces. +pub(super) fn cpp_without_annotations(text: &str) -> Cow<'_, str> { + if !re!(r"[*&]\s*[A-Z][A-Z0-9]*_|[\w\]]\s+[A-Z][A-Z0-9]*_[A-Z0-9_]*\s*[(;=,)]").is_match(text) { return Cow::Borrowed(text); } + let code = masked_code(text); + let mut spans = Vec::new(); + pointer_annotation_spans(&code, text, &mut spans); + declarator_attribute_spans(&code, text, &mut spans); + if spans.is_empty() { return Cow::Borrowed(text); } + let mut out = text.as_bytes().to_vec(); + for (from, to) in spans { + for b in &mut out[from..to] { if *b != b'\n' && *b != b'\r' { *b = b' '; } } + } + // Only ASCII macro names and their (ASCII-delimited) arguments' bytes are blanked whole. + Cow::Owned(String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())) +} + +#[cfg(test)] +mod tests { + use super::cpp_without_annotations; + + #[test] + fn blanks_declaration_annotations_only() { + let text = "Printer* PROTOBUF_NONNULL printer_;\nVersionSet* const versions_ GUARDED_BY(mutex_);\nint n = a * MAX_LEN + 1;\nFoo DEFAULT_OPTIONS;\nreturn x * FOO_BAR;\n// Foo* PROTOBUF_NONNULL c;\n"; + let out = cpp_without_annotations(text); + assert_eq!(out.len(), text.len()); + let blank = |name: &str| " ".repeat(name.len()); + let expected = format!("Printer* {} printer_;\nVersionSet* const versions_ {};\n", blank("PROTOBUF_NONNULL"), blank("GUARDED_BY(mutex_)")); + assert!(out.starts_with(&expected), "{out}"); + assert!(out.contains("a * MAX_LEN + 1") && out.contains("Foo DEFAULT_OPTIONS;") && out.contains("x * FOO_BAR") && out.contains("Foo* PROTOBUF_NONNULL c;")); + } +} diff --git a/codegraph-kernel/src/resolve/cpp_implicit_this.rs b/codegraph-kernel/src/resolve/cpp_implicit_this.rs index 4e9ae4f954..f01bdf8d19 100644 --- a/codegraph-kernel/src/resolve/cpp_implicit_this.rs +++ b/codegraph-kernel/src/resolve/cpp_implicit_this.rs @@ -118,16 +118,36 @@ impl KernelResolver { if r.language != "cpp" || r.reference_kind != "calls" || !re!(r"^[A-Za-z_][A-Za-z0-9_]*$").is_match(name) { return Ok(None); } let Some(receiver) = self.cpp_implicit_receiver(r) else { return Ok(None) }; let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(None) }; - let Some(owner) = self.cpp_member_owner(&caller)? else { return Ok(None) }; + let Some(node) = self.cpp_implicit_this_lookup(name, receiver, &caller, r)? else { return Ok(None) }; + // (A recursive call names the function itself, which no local shadows.) + if receiver == Receiver::Bare && name != caller.name && self.cpp_local_name(name, &caller, r)? { return Ok(None); } + Ok(Some(KCand { node, confidence: 0.9, resolved_by: "instance-method" })) + } + + /// The method a C++ call of `name` written on `this` reaches from `caller` by C++ lookup, as + /// `cpp_implicit_this` finds it (cppImplicitThisMethod): what a bare call of a method in a + /// member function or a gtest body returns is that method's. + pub(super) fn cpp_implicit_this_method(&mut self, name: &str, caller: &Arc, r: &ResolveRefIn) -> Res>> { + self.cpp_implicit_this_lookup(name, Receiver::This, caller, r) + } + + /// The method a C++ call of `name` with no receiver, or on `this`, reaches from `caller`: of the + /// caller's class, a class it derives from, or one it is nested in. A gtest `TEST_F(Fixture, …)` + /// or `TEST_P` body is a method of a class gtest derives from `Fixture`, so the fixture's methods + /// are its own: rocksdb's tests call `DBTestBase`'s `Put(…)` and `NewIterator(…)` bare. None when + /// the caller is no member function, or no class on the way has a method of that name. (Whether + /// a local of the caller's shadows it is the caller's to ask.) + fn cpp_implicit_this_lookup(&mut self, name: &str, receiver: Receiver, caller: &Arc, r: &ResolveRefIn) -> Res>> { + let owner = match self.cpp_member_owner(caller)? { + Some(owner) => owner, + None => match self.cpp_gtest_fixture(caller, r)? { Some(fixture) => fixture, None => return Ok(None) }, + }; let mut found = Lookup::Missing; - for scope in self.cpp_enclosing_classes(&owner, &caller)? { + for scope in self.cpp_enclosing_classes(&owner, caller)? { found = self.cpp_scope_method(&scope, name, receiver, r)?; if !matches!(found, Lookup::Missing) || receiver == Receiver::This { break; } } - let Lookup::Found(node) = found else { return Ok(None) }; - // (A recursive call names the function itself, which no local shadows.) - if receiver == Receiver::Bare && name != caller.name && self.cpp_local_name(name, &caller, r)? { return Ok(None); } - Ok(Some(KCand { node, confidence: 0.9, resolved_by: "instance-method" })) + Ok(match found { Lookup::Found(node) => Some(node), _ => None }) } /// How a C++ call the extractor recorded by its bare name is written, read at its column: @@ -272,7 +292,7 @@ impl KernelResolver { if !inline.is_empty() || !out_of_line.is_empty() { let mut overloads = inline; overloads.extend(prefer_call_site_file(out_of_line, &r.file_path)); - return Ok(Lookup::Found(self.cpp_overload_for(overloads, name, r))); + return Ok(Lookup::Found(self.cpp_overload_for(overloads, name, r)?)); } for decl in &scope.decls { let words = self.cpp_member_macro_arguments(decl); @@ -282,7 +302,7 @@ impl KernelResolver { for decl in &scope.decls { for base in self.supertype_nodes(&decl.id)? { if !class_kind(&base.kind) { continue; } - if receiver == Receiver::Bare && self.cpp_dependent_base(decl, &base)? { continue; } + if receiver == Receiver::Bare && self.cpp_dependent_base(decl, &base)? && !self.cpp_using_declares(decl, &base, name) { continue; } let mut seen = HashSet::from([scope.qualified_name.clone()]); let Some(inherited) = self.cpp_method_of(&base, name, r, 1, &mut seen)? else { continue }; // Its class's other overloads, which the arguments may fit better (rocksdb's @@ -291,7 +311,7 @@ impl KernelResolver { .iter().filter(|n| n.id != inherited.id && n.kind == "method" && c_family(&n.language)).cloned().collect(); let mut overloads = vec![inherited]; overloads.extend(prefer_call_site_file(siblings, &r.file_path)); - return Ok(Lookup::Found(self.cpp_overload_for(overloads, name, r))); + return Ok(Lookup::Found(self.cpp_overload_for(overloads, name, r)?)); } } Ok(Lookup::Missing) @@ -303,8 +323,11 @@ impl KernelResolver { let own: Vec<_> = self.nodes_by_qualified_name(&format!("{}::{name}", cls.qualified_name))? .iter().filter(|n| n.kind == "method" && c_family(&n.language)).cloned().collect(); if !own.is_empty() { - let own = self.cpp_visible_first(own, r)?; - return Ok(prefer_call_site_file(own, &r.file_path).into_iter().next()); + // Of its overloads, the one the call's arguments fit; a chained call `inner(…).name(…)` + // keeps the first, since the arguments at its position are the inner call's. + let own = prefer_call_site_file(self.cpp_visible_first(own, r)?, &r.file_path); + if r.reference_name.contains("().") || own.len() < 2 { return Ok(own.into_iter().next()); } + return Ok(Some(self.cpp_overload_for(own, name, r)?)); } seen.insert(cls.qualified_name.clone()); if depth >= 4 || !self.supertypes_complete { return Ok(None); } @@ -317,22 +340,6 @@ impl KernelResolver { Ok(None) } - /// Of one class's overloads of `name` (in preference order), the one the call's arguments fit best. - fn cpp_overload_for(&mut self, overloads: Vec>, name: &str, r: &ResolveRefIn) -> Arc { - let mut best = overloads[0].clone(); - if overloads.len() == 1 { return best; } - let Some(args) = self.call_arguments(r, name) else { return best }; - let mut best_fit = self.cpp_overload_fit(&best, &args, r); - for n in overloads.into_iter().skip(1) { - let fit = self.cpp_overload_fit(&n, &args, r); - if fit > best_fit { - best = n; - best_fit = fit; - } - } - best - } - /// The words in the arguments of the macros a C++ class declaration calls among its members, /// like `Message` in protobuf's `LOCAL_VAR_ACCESSOR(Message*, Message);`: such a macro can /// declare a member the index has no node for (`Get##name` makes `GetMessage`). @@ -385,6 +392,22 @@ impl KernelResolver { /// Whether `base`, a base of the C++ class declaration `decl`, depends on the template /// parameters of `decl` or a class around it: `template class Foo : public Base`. /// A bare name is not looked up in such a base. + /// Whether the C++ class declaration `decl` brings `name` in from its base `base` with a + /// using-declaration (cppUsingDeclares): rocksdb's `FullTypedCacheHelperFns` writes `using + /// BasicTypedCacheHelperFns::DownCastValue;`, and a bare `DownCastValue(v)` in it then + /// finds the base's, though the base depends on the class's template parameters. + pub(super) fn cpp_using_declares(&mut self, decl: &KNode, base: &KNode, name: &str) -> bool { + let Some(source) = self.read_file(&decl.file_path) else { return false }; + let lines = source.cpp_code_lines(); + let from = (decl.start_line - 1).max(0) as usize; + let to = (decl.end_line.max(decl.start_line) as usize).min(lines.len()); + if from >= to { return false; } + let body = lines[from..to].join("\n"); + re!(r"\busing\s+((?:::\s*)?[A-Za-z_][\w:]*(?:\s*<[^;{}]*>)?)\s*::\s*([A-Za-z_]\w*)\s*;").captures_iter(&body).any(|m| { + &m[2] == name && !m[1].trim_start().starts_with("namespace") && cpp_last_segment(strip_template_arguments(&m[1]).trim()) == base.name + }) + } + pub(super) fn cpp_dependent_base(&mut self, decl: &KNode, base: &KNode) -> Res { let parameters = self.cpp_template_parameter_names(decl)?; if parameters.is_empty() { return Ok(false); } diff --git a/codegraph-kernel/src/resolve/cpp_overloads.rs b/codegraph-kernel/src/resolve/cpp_overloads.rs new file mode 100644 index 0000000000..b743ce5c4c --- /dev/null +++ b/codegraph-kernel/src/resolve/cpp_overloads.rs @@ -0,0 +1,245 @@ +//! Which of a C++ class's overloads a call's arguments fit: by their count and string literals +//! (`cpp_overload_fit`), then, among overloads those fit alike, by the types the arguments have +//! where the call reads them. rocksdb's `Insert(ch)` on a `char ch` is `Insert(char key, …)`, not +//! `Insert(const std::string& key, …)`, and `db->Get(ro, key, &value)` is not the five-parameter +//! `Get(…, std::string* timestamp)`. +//! +//! Port of upstream's `cppOverloadFor`, `cppArgumentType` and `cppArgumentTypeFit` (name-matcher.ts +//! at a6ceb047, #2541); read the original with `git show a6ceb047:src/resolution/name-matcher.ts`. +//! The arguments are read from the syntax tree: a literal's kind, or the type a name, `&name`, +//! `name[i]` or another expression has as written where it is declared. +use super::cpp_implicit_this::strip_template_arguments; +use super::cpp_range_for::CppWritten; +use super::iteration::{descendant_for_position, named_children}; +use super::receivers::cpp_last_segment; +use super::*; + +/// What a C++ call's argument is: a literal's kind, or the type a name or expression has as written. +pub(super) enum CppArgument { + Char, + Number, + Zero, + Bool, + Null, + Type(String), +} + +/// What a C++ type is, for an argument to fit a parameter of it. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum CppTypeCategory { + String, + CString, + Char, + Number, + Bool, + Pointer, + /// `std::nullptr_t`. + Null, + Class, +} + +const CHAR_TYPES: &[&str] = &["char", "wchar_t", "char8_t", "char16_t", "char32_t"]; +const NUMBER_TYPES: &[&str] = &[ + "int", "unsigned", "signed", "short", "long", "float", "double", "size_t", "ssize_t", "ptrdiff_t", + "int8_t", "int16_t", "int32_t", "int64_t", "uint8_t", "uint16_t", "uint32_t", "uint64_t", "intptr_t", "uintptr_t", +]; +const STRING_TYPES: &[&str] = &["string", "wstring", "string_view", "wstring_view", "basic_string", "basic_string_view"]; + +/// The category of the written C++ type `ty`: a pointer's, a string's, a character's, a number's, a +/// class's. +pub(super) fn cpp_type_category(ty: &str) -> CppTypeCategory { + use CppTypeCategory::*; + let stripped = strip_template_arguments(ty); + let flat = re!(r"\b(?:const|volatile|struct|class|typename|enum)\b").replace_all(&stripped, " ").replace('&', " "); + let flat = flat.trim(); + let words: Vec<&str> = flat.split(|c: char| c == '*' || c.is_whitespace()).filter(|w| !w.is_empty()).collect(); + let last = cpp_last_segment(words.last().copied().unwrap_or("")); + if flat.contains('*') { + return if CHAR_TYPES.contains(&last.as_str()) && flat.matches('*').count() == 1 { CString } else { Pointer }; + } + if last == "nullptr_t" { return Null; } + if last == "bool" { return Bool; } + if CHAR_TYPES.contains(&last.as_str()) && words.iter().all(|w| CHAR_TYPES.contains(w) || *w == "signed" || *w == "unsigned") { return Char; } + if NUMBER_TYPES.contains(&last.as_str()) || (!words.is_empty() && words.iter().all(|w| NUMBER_TYPES.contains(w))) { return Number; } + if STRING_TYPES.contains(&last.as_str()) { String } else { Class } +} + +/// A C++ parameter declaration's type: without its default argument and its name. +pub(super) fn cpp_parameter_type(param: &str) -> String { + let mut text = param; + let mut depth = 0i32; + for (i, c) in param.char_indices() { + match c { + '<' | '(' | '[' => depth += 1, + '>' | ')' | ']' => depth -= 1, + '=' if depth == 0 => { text = ¶m[..i]; break; } + _ => {} + } + } + let text = text.trim(); + // The name, when a type comes before it. + if let Some(named) = re!(r"^(.*[\w>*&\s])\b[A-Za-z_]\w*\s*(?:\[[^\]]*\])?$").captures(text) { + let ty = named[1].trim(); + if re!(r"[A-Za-z_]").is_match(&named[1]) && !re!(r"^(?:const|volatile|unsigned|signed|struct|class|enum)\s*$").is_match(ty) { + return ty.to_string(); + } + } + text.to_string() +} + +/// How a literal fits a parameter of each category. `nullptr` is a `std::nullptr_t` exactly, and a +/// pointer only by conversion: protobuf's `ptr.reset(nullptr)` is `reset(std::nullptr_t)`, not +/// `reset(T*)` (#2554). +fn literal_fit(argument: &CppArgument, category: CppTypeCategory) -> i64 { + use CppTypeCategory::*; + match (argument, category) { + (CppArgument::Char, Char) => 3, + (CppArgument::Char, Number) => 1, + (CppArgument::Char, String | CString | Pointer | Null) => -3, + (CppArgument::Number, Number) => 2, + (CppArgument::Number, Char) => 1, + (CppArgument::Number, String | CString | Pointer | Null) => -3, + (CppArgument::Zero, Number) => 2, + (CppArgument::Zero, Char | Pointer | CString | Null) => 1, + (CppArgument::Zero, String) => -3, + (CppArgument::Bool, Bool) => 3, + (CppArgument::Bool, Number) => 1, + (CppArgument::Bool, String | CString | Pointer | Null) => -3, + (CppArgument::Null, Null) => 4, + (CppArgument::Null, Pointer | CString) => 3, + (CppArgument::Null, Class) => 1, + (CppArgument::Null, String | Char | Number) => -3, + (CppArgument::Null, Bool) => -1, + _ => 0, + } +} + +impl KernelResolver { + /// Of one class's overloads of `name` (in preference order), the one the call's arguments fit + /// best: by their count and string literals, then, among overloads those fit alike, by the + /// types the arguments have where the call reads them. A deleted overload (`void reset(T*) = + /// delete;`) is none a call that compiles reaches, so it is only taken when every one is. + pub(super) fn cpp_overload_for(&mut self, candidates: Vec>, name: &str, r: &ResolveRefIn) -> Res> { + if candidates.len() == 1 { return Ok(candidates[0].clone()); } + let callable: Vec<_> = candidates.iter().filter(|n| !self.cpp_deleted_function(n, name)).cloned().collect(); + let overloads = if callable.is_empty() { candidates } else { callable }; + if overloads.len() == 1 { return Ok(overloads[0].clone()); } + let Some(args) = self.call_arguments(r, name) else { return Ok(overloads[0].clone()) }; + let fits: Vec = overloads.iter().map(|n| self.cpp_overload_fit(n, &args, r)).collect(); + let top = fits.iter().copied().max().unwrap_or(0); + let tied: Vec<_> = overloads.iter().zip(&fits).filter(|(_, f)| **f == top).map(|(n, _)| n.clone()).collect(); + if tied.len() == 1 || args.is_empty() { return Ok(tied[0].clone()); } + let Some(types) = self.cpp_call_argument_types(name, r)? else { return Ok(tied[0].clone()) }; + if types.iter().all(Option::is_none) { return Ok(tied[0].clone()); } + let mut best = tied[0].clone(); + let mut best_fit = self.cpp_argument_type_fit(&best, &types, r)?; + for n in tied.into_iter().skip(1) { + let fit = self.cpp_argument_type_fit(&n, &types, r)?; + if fit > best_fit { + best = n; + best_fit = fit; + } + } + Ok(best) + } + + /// Whether the C++ function or method `n`, named `name`, is declared deleted: from its name to + /// the declaration's `;` or body, it says `= delete` (protobuf's `void reset(T* + /// PROTOBUF_NULLABLE) = delete;`). + fn cpp_deleted_function(&mut self, n: &KNode, name: &str) -> bool { + let Some(source) = self.read_file(&n.file_path) else { return false }; + let from = (n.start_line - 1).max(0) as usize; + let text = (from..from + 12).filter_map(|i| source.get(i).map(String::as_str)).collect::>().join("\n"); + let Ok(word) = shared_regex(&format!(r"\b{}\s*\(", regex::escape(name))) else { return false }; + let Some(at) = word.find(&text) else { return false }; + let declaration = text[at.start()..].split([';', '{']).next().unwrap_or(""); + re!(r"=\s*delete\b").is_match(declaration) + } + + /// How well the read argument types `types` fit the parameters of the overload `n`: the same + /// type, or a character to a character, scores; a character, number or pointer to a string, or + /// a string or pointer to a number, counts against. Classes of other names score nothing: a + /// conversion may join them. + fn cpp_argument_type_fit(&mut self, n: &KNode, types: &[Option], r: &ResolveRefIn) -> Res { + let Some(params) = self.declaration_parameters(n, r) else { return Ok(0) }; + let params: Vec<_> = params.into_iter().filter(|p| p != "void").collect(); + let mut score = 0; + for (argument, param) in types.iter().zip(¶ms) { + let Some(argument) = argument else { continue }; + let param = cpp_parameter_type(param); + if param.contains("...") { continue; } + let category = cpp_type_category(¶m); + score += match argument { + CppArgument::Type(text) => self.cpp_written_type_fit(text, ¶m, category)?, + literal => literal_fit(literal, category), + }; + } + Ok(score) + } + + /// How an argument written as the type `argument` fits a parameter of the type `param`. + fn cpp_written_type_fit(&mut self, argument: &str, param: &str, category: CppTypeCategory) -> Res { + use CppTypeCategory::*; + let argument_category = cpp_type_category(argument); + let pointer = |c: CppTypeCategory| c == Pointer || c == CString; + if self.normalize_cpp_type_name(argument, false)? == self.normalize_cpp_type_name(param, false)? && pointer(argument_category) == pointer(category) { return Ok(3); } + if argument_category == category && argument_category != Class { return Ok(2); } + let textual = |c: CppTypeCategory| c == String || c == CString; + Ok(if matches!(argument_category, Char | Number | Pointer) && category == String + || (textual(argument_category) || argument_category == Pointer) && matches!(category, Char | Number) + || matches!(argument_category, Char | Number) && matches!(category, Pointer | CString) { -3 } else { 0 }) + } + + /// What each argument of the call of `name` at `r` is, where that can be read (cppArgumentType): + /// a character, number, `true`/`false` or `nullptr` literal, or the written type of a name the + /// calling function declares, of `&name`, of `name[i]` (rocksdb's `TryLock(column_family[i], + /// keys[i], …)` on a `std::vector`), or of an expression. None for the call when it + /// can't be found. (String literals are `cpp_overload_fit`'s.) + pub(super) fn cpp_call_argument_types(&mut self, name: &str, r: &ResolveRefIn) -> Res>>> { + let Some(source) = self.read_file(&r.file_path) else { return Ok(None) }; + let Some(tree) = self.parsed_tree(&source, r) else { return Ok(None) }; + let callee = shared_regex(&format!(r"(?:^|[.:>])\s*{}(?:\s*<[\s\S]*>)?\s*$", regex::escape(name)))?; + let mut call = descendant_for_position(tree.root_node(), source.text(), ((r.line - 1).max(0) as usize, r.column.max(0) as usize)); + loop { + if call.kind() == "call_expression" && call.child_by_field_name("function").is_some_and(|f| callee.is_match(&source.text()[f.byte_range()])) { break; } + let Some(parent) = call.parent() else { return Ok(None) }; + call = parent; + } + let Some(arguments) = call.child_by_field_name("arguments") else { return Ok(None) }; + let mut types = Vec::new(); + for argument in named_children(arguments).into_iter().filter(|n| !n.kind().contains("comment")) { + types.push(self.cpp_argument_type(argument, &source, r)?); + } + Ok(Some(types)) + } + + fn cpp_argument_type(&mut self, node: tree_sitter::Node, source: &Rc, r: &ResolveRefIn) -> Res> { + let text = source.text()[node.byte_range()].trim(); + Ok(match node.kind() { + "char_literal" => Some(CppArgument::Char), + "true" | "false" => Some(CppArgument::Bool), + "null" | "nullptr" => Some(CppArgument::Null), + "identifier" if text == "NULL" => Some(CppArgument::Null), + "number_literal" if re!(r"^0+[uUlL]*$").is_match(text) => Some(CppArgument::Zero), + "number_literal" => Some(CppArgument::Number), + "unary_expression" if re!(r"^[-+]\s*[0-9.]").is_match(text) => Some(CppArgument::Number), + "string_literal" | "raw_string_literal" | "concatenated_string" => None, + // `&value`: a pointer to what the name is. + "pointer_expression" => { + let operator = node.child_by_field_name("operator").map(|o| o.kind()); + match node.child_by_field_name("argument").filter(|a| a.kind() == "identifier" && operator == Some("&")) { + Some(name) => self.cpp_written_name(name, source, r)?.map(|w| CppArgument::Type(format!("{}*", w.text))), + None => self.cpp_written_expression(node, source, r)?.map(|w| CppArgument::Type(w.text)), + } + } + "identifier" => self.cpp_written_name(node, source, r)?.map(|w| CppArgument::Type(w.text)), + // `keys[i]`: an element of the container, or what the pointer points at. + "subscript_expression" => { + let Some(container) = node.child_by_field_name("argument").filter(|a| a.kind() == "identifier") else { return Ok(None) }; + let Some(written) = self.cpp_written_name(container, source, r)? else { return Ok(None) }; + self.cpp_subscript_element(written, r)?.map(|w| CppArgument::Type(w.text)) + } + _ => self.cpp_written_expression(node, source, r)?.map(|w: CppWritten| CppArgument::Type(w.text)), + }) + } +} diff --git a/codegraph-kernel/src/resolve/cpp_range_for.rs b/codegraph-kernel/src/resolve/cpp_range_for.rs index 513c6e53c3..b1a2311bda 100644 --- a/codegraph-kernel/src/resolve/cpp_range_for.rs +++ b/codegraph-kernel/src/resolve/cpp_range_for.rs @@ -17,7 +17,7 @@ //! gives no element, and the call resolves as it did before. use super::cpp_aliases::type_segments; use super::cpp_implicit_this::strip_template_arguments; -use super::cpp_receivers::{cpp_ast_declared_type, cpp_binding_type, cpp_declarator_name, cpp_held_type}; +use super::cpp_receivers::{cpp_ast_declared_type, cpp_binding_type, cpp_declarator_name, cpp_held_type, cpp_holder_arguments}; use super::iteration::{descendant_for_position, named_children}; use super::*; @@ -31,13 +31,15 @@ pub(super) struct CppWritten { } /// The standard library's and abseil's containers whose elements are their first template -/// argument. A map's elements are key and value pairs, which no call this resolves is made on. +/// argument. const LIBRARY_SEQUENCES: &[&str] = &[ "vector", "list", "forward_list", "deque", "set", "multiset", "unordered_set", "unordered_multiset", "array", "span", "initializer_list", "Span", "InlinedVector", "FixedArray", "flat_hash_set", "node_hash_set", "btree_set", "btree_multiset", ]; /// Widely used project containers of the same kind: protobuf's repeated fields, rocksdb's `autovector`. const PROJECT_SEQUENCES: &[&str] = &["RepeatedField", "RepeatedPtrField", "autovector"]; +/// The standard library's and abseil's maps, whose elements are `std::pair`s of a key and a value. +const LIBRARY_MAPS: &[&str] = &["map", "multimap", "unordered_map", "unordered_multimap", "flat_hash_map", "node_hash_map", "btree_map", "btree_multimap"]; const MAX_DEPTH: u32 = 4; /// A written type without nullability annotations (`const FieldDescriptor* absl_nonnull`) or @@ -106,8 +108,7 @@ pub(super) fn cpp_pointee(text: &str) -> Option { } } let plain = cpp_plain_type(text); - let held = re!(r"^(?:::)?std::(?:unique_ptr|shared_ptr|optional)\s*<(.*)>$").captures(&plain)?; - cpp_held_type(&held[1]) + cpp_held_type(cpp_holder_arguments(&plain)?) } /// The function declarator of a declarator that declares a function (`f()`, `*f()`, `&f()`), not @@ -163,7 +164,7 @@ fn type_depends_on(text: &str, parameters: &HashSet, depth: u32) -> bool let plain = cpp_plain_type(text); let primary = plain.split('<').next().unwrap_or("").trim().trim_start_matches("::").trim_end_matches(['*', '&', ' ']); if parameters.contains(primary.split("::").next().unwrap_or("").trim()) { return true; } - if let Some(held) = re!(r"^(?:::)?std::(?:unique_ptr|shared_ptr|optional)\s*<(.*)>[\s*&]*$").captures(&plain).and_then(|c| cpp_held_type(&c[1])) { + if let Some(held) = cpp_holder_arguments(plain.trim_end_matches(['*', '&', ' '])).and_then(cpp_held_type) { return type_depends_on(&held, parameters, depth + 1); } re!(r"^(?:::)?(?:std|absl)::").is_match(&plain) && re!(r"[A-Za-z_]\w*").find_iter(&plain).any(|word| parameters.contains(word.as_str())) @@ -366,7 +367,7 @@ impl KernelResolver { if depth >= 4 || !self.supertypes_complete { return Ok(None); } for decl in &decls { for base in self.supertype_nodes(&decl.id)? { - if !cpp_class_node(&base) || self.cpp_dependent_base(decl, &base)? { continue; } + if !cpp_class_node(&base) || (self.cpp_dependent_base(decl, &base)? && !self.cpp_using_declares(decl, &base, field)) { continue; } if let Some(member) = self.cpp_member_written_in(&base.qualified_name, field, r, depth + 1, seen)? { return Ok(Some(member)); } } } @@ -390,7 +391,7 @@ impl KernelResolver { /// The fixture class of a gtest `TEST_F(Fixture, Name)` / `TEST_P(Fixture, Name)` body, which is /// a method of a class derived from it. - fn cpp_gtest_fixture(&mut self, caller: &KNode, r: &ResolveRefIn) -> Res> { + pub(super) fn cpp_gtest_fixture(&mut self, caller: &KNode, r: &ResolveRefIn) -> Res> { if !matches!(caller.name.as_str(), "TEST_F" | "TEST_P") || !matches!(caller.kind.as_str(), "function" | "method") { return Ok(None); } let Some(source) = self.read_file(&caller.file_path) else { return Ok(None) }; let from = (caller.start_line - 1).max(0) as usize; @@ -503,7 +504,9 @@ impl KernelResolver { }, "this" => { let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(None) }; - Ok(self.cpp_member_owner(&caller)?.map(|owner| CppWritten { text: format!("::{owner}*"), site: r.clone() })) + // (A gtest `TEST_F` body's object is of a class derived from its fixture.) + let owner = match self.cpp_member_owner(&caller)? { Some(owner) => Some(owner), None => self.cpp_gtest_fixture(&caller, r)? }; + Ok(owner.map(|owner| CppWritten { text: format!("::{owner}*"), site: r.clone() })) } "identifier" => self.cpp_name_written(node, source, r, depth), "pointer_expression" => { @@ -542,7 +545,7 @@ impl KernelResolver { return Ok(Some(CppWritten { text: format!("std::{}_ptr<{}>", &made[1], made[2].trim()), site: r.clone() })); } // `std::unique_ptr(…)`: the smart pointer or optional it makes. - if re!(r"^(?:::\s*)?std\s*::\s*(?:unique_ptr|shared_ptr|optional)\s*<[\s\S]+>$").is_match(callee) { + if cpp_holder_arguments(&callee.split_whitespace().collect::()).is_some() { return Ok(Some(CppWritten { text: callee.to_string(), site: r.clone() })); } match function.kind() { @@ -556,16 +559,20 @@ impl KernelResolver { // inherited method first, then the function looked up from the caller's scope // outwards, then the class it constructs. "identifier" | "qualified_identifier" | "template_function" => { + // `create_iterator()` on a `std::function create_iterator`: what + // its function type returns. + if function.kind() == "identifier" { + if let Some(wrapper) = self.cpp_name_written(function, source, r, depth + 1)? { + if let Some(returned) = cpp_wrapper_return_type(&wrapper) { return Ok(Some(returned)); } + } + } let spelled: String = strip_template_arguments(callee).split_whitespace().collect(); if !re!(r"^(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*$").is_match(&spelled) { return Ok(None); } let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(None) }; + // `NewIterator(…)` in a method, or in a gtest body: what the method of its + // class, or of a class it derives from, returns, looked up as on `this`. if !spelled.contains("::") { - if let Some(owner) = self.cpp_member_owner(&caller)? { - if let Some(cls) = self.cpp_class_decls(&owner)?.into_iter().next() { - let own = self.cpp_methods_named(&cls, &spelled, r)?; - if !own.is_empty() { return self.cpp_return_type_of(&own, r); } - } - } + if let Some(own) = self.cpp_implicit_this_method(&spelled, &caller, r)? { return self.cpp_return_type_of(&[own], r); } } let scopes: Vec = caller.qualified_name.split("::").map(str::to_string).collect(); for i in (0..scopes.len()).rev() { @@ -585,6 +592,24 @@ impl KernelResolver { } } + /// The declared type of the name `node` in `r`'s caller, as `cpp_name_written` reads it. + pub(super) fn cpp_written_name(&mut self, node: TsNode, source: &Rc, r: &ResolveRefIn) -> Res> { + self.cpp_name_written(node, source, r, 0) + } + + /// The type of a C++ expression in `r`'s caller, as `cpp_expression_written` reads it. + pub(super) fn cpp_written_expression(&mut self, node: TsNode, source: &Rc, r: &ResolveRefIn) -> Res> { + self.cpp_expression_written(node, source, r, 0) + } + + /// What `x[i]` is when `x` has the type `container`: what a pointer points at, or a + /// container's element. + pub(super) fn cpp_subscript_element(&mut self, container: CppWritten, r: &ResolveRefIn) -> Res> { + let Some(followed) = self.cpp_follow_written_aliases(container, r)? else { return Ok(None) }; + if top_level_pointer(&followed.text) { return Ok(cpp_pointee(&followed.text).map(|text| CppWritten { text, site: followed.site })); } + self.cpp_range_element_of(followed, r, 0) + } + /// The class of the object a member expression `x.m` or `x->m` names a member of, and `m`. fn cpp_member_of_expression(&mut self, member: TsNode, source: &Rc, r: &ResolveRefIn, depth: u32) -> Res, String)>> { let (Some(argument), Some(field)) = (member.child_by_field_name("argument"), member.child_by_field_name("field")) else { return Ok(None) }; @@ -610,6 +635,7 @@ impl KernelResolver { let name = &source.text()[node.byte_range()]; let mut at = node; while let Some(parent) = at.parent() { + if let Some(bound) = self.cpp_structured_binding_written(parent, at, name, source, r)? { return Ok(bound); } if let Some(raw) = cpp_binding_type(parent, at, name, source) { if raw.is_empty() || raw.contains('[') { return Ok(None); } if !is_cpp_auto(&raw) { return Ok(Some(CppWritten { text: raw, site: r.clone() })); } @@ -633,6 +659,54 @@ impl KernelResolver { self.cpp_caller_member(name, r) } + /// The type a structured binding in `parent` gives `name` where `at` is: the element at the + /// name's place of the pair or tuple bound (cppStructuredBindingType). That is an element of + /// the range a range-for over the binding iterates, when `at` is the loop's body (rocksdb's + /// `for (auto& [cfh, iter] : cfh_iter_pairs_)`, a map's key and value), or the initializer of + /// a declaration before `at` (`auto [status, iter] = Open();`). + /// - `None`: `parent` binds no such name there. + /// - `Some(None)`: it does, but the type can't be read. + pub(super) fn cpp_structured_binding_written(&mut self, parent: TsNode, at: TsNode, name: &str, source: &Rc, r: &ResolveRefIn) -> Res>> { + let bound = if parent.kind() == "for_range_loop" { + if !parent.child_by_field_name("body").is_some_and(|body| body.id() == at.id()) { return Ok(None); } + let Some(place) = parent.child_by_field_name("declarator").and_then(|d| binding_place(d, name, source)) else { return Ok(None) }; + self.cpp_range_for_element(parent, source, r, 0)?.map(|value| (value, place)) + } else { + let children = match parent.kind() { + "compound_statement" | "declaration_list" | "translation_unit" => named_children(parent), + _ => statement_initializer(parent).into_iter().collect(), + }; + let mut found = None; + for declaration in children.into_iter().rev().filter(|n| n.kind() == "declaration" && n.end_byte() <= at.start_byte()) { + let mut cursor = declaration.walk(); + let declarators: Vec<_> = declaration.children_by_field_name("declarator", &mut cursor).collect(); + if let Some((declarator, place)) = declarators.iter().find_map(|d| binding_place(*d, name, source).map(|p| (*d, p))) { + found = Some((declarator, place)); + break; + } + // A nearer declaration of the name of any other kind is the name's. + if declarators.iter().any(|d| cpp_declarator_name(*d, source) == name) { return Ok(None); } + } + let Some((declarator, place)) = found else { return Ok(None) }; + match declarator.child_by_field_name("value").filter(|_| declarator.kind() == "init_declarator").filter(|v| !matches!(v.kind(), "initializer_list" | "argument_list")) { + Some(value) => self.cpp_initializer_written(value, source, r)?.map(|value| (value, place)), + None => None, + } + }; + let Some((value, place)) = bound else { return Ok(Some(None)) }; + Ok(Some(self.cpp_binding_elements(value, r)?.and_then(|elements| elements.into_iter().nth(place)))) + } + + /// What a structured binding of a value of the type `value` names, in order: a `std::pair`'s or + /// `std::tuple`'s elements. None for any other type. + fn cpp_binding_elements(&mut self, value: CppWritten, r: &ResolveRefIn) -> Res>> { + let Some(followed) = self.cpp_follow_written_aliases(value, r)? else { return Ok(None) }; + let plain = cpp_plain_type(&followed.text); + if !type_segments(&plain).is_some_and(|parts| parts.len() == 2 && parts[0] == "std" && (parts[1] == "pair" || parts[1] == "tuple")) { return Ok(None); } + if top_level_pointer(&plain) { return Ok(None); } + Ok(template_arguments(&plain).map(|arguments| arguments.into_iter().map(|text| CppWritten { text: cpp_plain_type(&text), site: followed.site.clone() }).collect())) + } + /// The element type of a range of the type `range`, as written: a container's first template /// argument, or, for one of the project's classes, what its `begin()` returns, dereferenced. fn cpp_range_element_of(&mut self, range: CppWritten, r: &ResolveRefIn, hops: u32) -> Res> { @@ -641,6 +715,11 @@ impl KernelResolver { if top_level_pointer(&range.text) { return Ok(None); } let Some(parts) = type_segments(&range.text) else { return Ok(None) }; let last = parts.last().map(String::as_str).unwrap_or(""); + // A map's elements pair its key with its value. + if LIBRARY_MAPS.contains(&last) && (parts[0] == "std" || parts[0] == "absl") { + let Some([key, value]) = template_arguments(&range.text).and_then(|a| <[String; 2]>::try_from(a.into_iter().take(2).collect::>()).ok()) else { return Ok(None) }; + return Ok(Some(CppWritten { text: format!("std::pair", cpp_plain_type(&key), cpp_plain_type(&value)), site: range.site })); + } let library = LIBRARY_SEQUENCES.contains(&last); if library || PROJECT_SEQUENCES.contains(&last) { // A project type of a standard container's name, written without `std::` or `absl::`, @@ -690,6 +769,39 @@ impl KernelResolver { } } +/// Where `name` is in the structured binding `declarator` writes (`[cfh, iter]` in `auto& [cfh, +/// iter]`, `auto [a, b] = …`), when it writes one. +fn binding_place(mut declarator: TsNode, name: &str, source: &SourceFile) -> Option { + while matches!(declarator.kind(), "init_declarator" | "reference_declarator" | "pointer_declarator" | "parenthesized_declarator") { + declarator = declarator.child_by_field_name("declarator").or_else(|| declarator.named_child(0))?; + } + if declarator.kind() != "structured_binding_declarator" { return None; } + named_children(declarator).iter().position(|n| &source.text()[n.byte_range()] == name) +} + +/// What calling a function wrapper of the written type `wrapper` returns, as written: what the +/// function type that is its template argument returns (`Iterator*` for +/// `std::function`). None for any other type. +fn cpp_wrapper_return_type(wrapper: &CppWritten) -> Option { + let plain = cpp_plain_type(&wrapper.text); + let plain = plain.trim_end_matches(['&', ' ']); + if !re!(r"^(?:::\s*)?(?:std\s*::\s*(?:function|move_only_function|copyable_function)|absl\s*::\s*(?:FunctionRef|AnyInvocable))\s*<").is_match(plain) { return None; } + let signature = template_arguments(plain)?.into_iter().next()?; + let mut depth = 0i32; + for (i, c) in signature.char_indices() { + match c { + '<' => depth += 1, + '>' => depth -= 1, + '(' if depth == 0 => { + let returned = signature[..i].trim(); + return (!returned.is_empty()).then(|| CppWritten { text: returned.to_string(), site: wrapper.site.clone() }); + } + _ => {} + } + } + None +} + #[cfg(test)] mod tests { use super::{cpp_pointee, template_arguments, cpp_plain_type}; diff --git a/codegraph-kernel/src/resolve/cpp_receivers.rs b/codegraph-kernel/src/resolve/cpp_receivers.rs index fc6fe5f583..c892ae4839 100644 --- a/codegraph-kernel/src/resolve/cpp_receivers.rs +++ b/codegraph-kernel/src/resolve/cpp_receivers.rs @@ -37,6 +37,14 @@ impl KernelResolver { // The initializer of an `auto` local, which gives it its type. let mut initializer = None; while let Some(parent) = scope.parent() { + // `for (auto& [cfh, iter] : pairs)`, `auto [status, iter] = Open();`: the pair or tuple element. + if let Some(bound) = self.cpp_structured_binding_written(parent, scope, name, &source, r)? { + let Some(written) = bound else { return Ok(Some(None)) }; + raw = Some(super::cpp_range_for::cpp_plain_type(&written.text)); + site = written.site; + element = true; + break; + } if let Some(ty) = cpp_binding_type(parent, scope, name, &source) { if super::cpp_range_for::is_cpp_auto(&ty) { initializer = super::cpp_range_for::binding_initializer(parent, scope, name, &source); } raw = Some(ty); @@ -100,9 +108,11 @@ impl KernelResolver { // An element that points at a class template (`const autovector*`). "->" if pointers == 1 && element => super::cpp_range_for::cpp_pointee(&raw).unwrap_or_default(), // A standard smart pointer or optional: `->` reaches a member of the type it holds. + // So does an `absl::StatusOr`, `absl::optional` or `absl::NoDestructor` of the library's. "->" if pointers == 0 => { - let Some(hit) = re!(r"^(?:const\s+)?(?:::)?std::(?:unique_ptr|shared_ptr|optional)\s*<(.*)>\s*&?$").captures(&raw) else { return Ok(Some(None)) }; - let Some(held) = cpp_held_type(&hit[1]) else { return Ok(None) }; + let Some(hit) = re!(r"^(?:const\s+)?(?:::)?((?:std|absl)::[A-Za-z_]\w*)\s*<(.*)>\s*&?$").captures(&raw) else { return Ok(Some(None)) }; + if !CPP_HOLDERS.contains(&&hit[1]) || !self.cpp_library_holder(&hit[1])? { return Ok(Some(None)); } + let Some(held) = cpp_held_type(&hit[2]) else { return Ok(None) }; if held.trim_start_matches("::").starts_with("std::") { return Ok(Some(None)); } held } @@ -280,7 +290,8 @@ impl KernelResolver { fn cpp_field_tree(&mut self, source: &Rc, r: &ResolveRefIn) -> Option> { let normalized = source.cpp_field_tree.get_or_init(|| { // Match extraction's padded access-macro rewrite; byte offsets stay fixed. - let text = re!(r"(?m)^([ \t]*)([A-Z][A-Z0-9]*_[A-Z0-9_]*)([ \t]*):([ \t]*(?://[^\n]*)?\r?$)").replace_all(source.text(), |m: ®ex::Captures| { + let blanked = super::cpp_annotations::cpp_without_annotations(source.text()); + let text = re!(r"(?m)^([ \t]*)([A-Z][A-Z0-9]*_[A-Z0-9_]*)([ \t]*):([ \t]*(?://[^\n]*)?\r?$)").replace_all(&blanked, |m: ®ex::Captures| { let name = &m[2]; let keyword = if name.contains("PRIVATE") { "private" } else if name.contains("PROTECTED") { "protected" } else if name.contains("PUBLIC") || matches!(name, "Q_SIGNALS" | "Q_SLOTS") { "public" } else { return m[0].to_string(); }; @@ -288,7 +299,7 @@ impl KernelResolver { if keyword.len() > width { return m[0].to_string(); } format!("{}{}{}:{}", &m[1], keyword, " ".repeat(width - keyword.len()), &m[4]) }); - if text.as_ref() == source.text() { return None; } + if text.as_ref() == blanked.as_ref() { return None; } crate::tree::parse_with_cached_parser(text.as_ref(), "cpp").ok().map(Rc::new) }).clone(); normalized.or_else(|| self.parsed_tree(source, r)) @@ -367,6 +378,27 @@ impl KernelResolver { } } +/// Library types whose `->` reaches the value they hold. +pub(super) const CPP_HOLDERS: &[&str] = &["std::unique_ptr", "std::shared_ptr", "std::optional", "absl::optional", "absl::StatusOr", "absl::NoDestructor"]; + +/// The template arguments of a holder `raw` writes (CPP_HOLDERS): `Iterator` of +/// `std::unique_ptr`, `FeatureResolver` of `absl::StatusOr`. +pub(super) fn cpp_holder_arguments(raw: &str) -> Option<&str> { + let hit = re!(r"^(?:::)?((?:std|absl)::[A-Za-z_]\w*)\s*<(.*)>$").captures(raw)?; + CPP_HOLDERS.contains(&hit.get(1)?.as_str()).then(|| hit.get(2).map(|m| m.as_str())).flatten() +} + +impl KernelResolver { + /// Whether the holder `spelled` names is the library's: no type of the project's has its name + /// (isCppLibraryHolder). + pub(super) fn cpp_library_holder(&mut self, spelled: &str) -> Res { + let last = spelled.rsplit("::").next().unwrap_or(spelled); + let suffix = format!("::{spelled}"); + Ok(!self.nodes_by_name(last)?.iter().any(|n| matches!(n.language.as_str(), "c" | "cpp") + && matches!(n.kind.as_str(), "class" | "struct" | "union" | "type_alias") && (n.qualified_name == spelled || n.qualified_name.ends_with(&suffix)))) + } +} + /// The class a subscript of a local reaches: the element of a standard sequence container, or of a /// one-dimensional array of a class (`Item items[2]`, which the declared type reads as `Item[]`). fn cpp_element_type(raw: &str) -> Option { diff --git a/codegraph-kernel/src/resolve/iteration.rs b/codegraph-kernel/src/resolve/iteration.rs index c2ccd91b07..56c1ef0c57 100644 --- a/codegraph-kernel/src/resolve/iteration.rs +++ b/codegraph-kernel/src/resolve/iteration.rs @@ -247,7 +247,9 @@ impl KernelResolver { return tree.clone(); } } - let tree = crate::tree::parse_with_cached_parser(file.text(), &r.language).ok().map(Rc::new); + // C++ is parsed as extraction parses it, with its declarations' annotation macros blanked. + let text = if r.language == "cpp" { super::cpp_annotations::cpp_without_annotations(file.text()) } else { std::borrow::Cow::Borrowed(file.text()) }; + let tree = crate::tree::parse_with_cached_parser(&text, &r.language).ok().map(Rc::new); self.tree_cache.put(key, (file.clone(), tree.clone())); tree } diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index de340c04c5..4eaf20043b 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -581,6 +581,8 @@ mod cpp_receivers; mod cpp_range_for; mod cpp_implicit_this; mod cpp_namespace; +mod cpp_annotations; +mod cpp_overloads; mod cpp_translation_unit; mod inherited_method; mod store; diff --git a/codegraph-kernel/src/resolve/overloads_upstream.rs b/codegraph-kernel/src/resolve/overloads_upstream.rs index aa109ab558..faee16c880 100644 --- a/codegraph-kernel/src/resolve/overloads_upstream.rs +++ b/codegraph-kernel/src/resolve/overloads_upstream.rs @@ -1,4 +1,5 @@ //! Source argument shape disambiguates a method's overload family. +use super::cpp_overloads::CppTypeCategory; use super::*; pub(super) fn split_top_level(text: &str) -> Vec { @@ -200,7 +201,7 @@ impl KernelResolver { }) .cloned() .collect(); - if siblings.is_empty() { + if siblings.is_empty() || (r.language == "cpp" && r.reference_name.contains("().")) { return Ok(hit); } if r.language == "swift" { @@ -711,14 +712,20 @@ impl KernelResolver { if flat.contains("...") { break; } - if !re!(r#"^(?:u8|u|U|L)?"|^FMT_STRING\s*\("#).is_match(arg) { + if !re!(r#"^(?:u8|u|U|L)?R?"|^FMT_STRING\s*\("#).is_match(arg) { continue; } - let wide_arg = arg.starts_with("L\""); + let wide_arg = re!(r#"^LR?""#).is_match(arg); let wide_param = re!(r"\bw(?:string|char_t|format|string_view)|wchar_t").is_match(param); + // A literal, raw ones too (protobuf's `p->Emit({…}, R"cc(…)cc")`), is a `const char*`: + // no pointer to anything else (rocksdb's `std::string* value`), number or character + // takes it, which counts for more than a class of another name. + let category = super::cpp_overloads::cpp_type_category(&super::cpp_overloads::cpp_parameter_type(param)); if wide_arg != wide_param && re!(r"string|char|Char|format").is_match(param) { score -= 2; + } else if n.language == "cpp" && matches!(category, CppTypeCategory::Pointer | CppTypeCategory::Number | CppTypeCategory::Bool | CppTypeCategory::Char) { + score -= 3; } else { score += if re!(r"string|char|Char|\bstr\b").is_match(param) { 3 @@ -740,9 +747,11 @@ impl KernelResolver { || !self.dart_top_level_visible(&hit.node, r)? || !self.dart_bare_member_visible(&hit.node, r)? { return Ok(false); } + // (A chained call `inner(…).name(…)` has the inner call's arguments at its position.) if r.language == "cpp" && r.reference_kind == "calls" && !super::cpp::is_cpp_constructor_ref(r) + && !r.reference_name.contains("().") { let candidate = self.retarget_overload( KCand { diff --git a/codegraph-kernel/src/resolve/receivers.rs b/codegraph-kernel/src/resolve/receivers.rs index 60726cecf5..e7b8f7adef 100644 --- a/codegraph-kernel/src/resolve/receivers.rs +++ b/codegraph-kernel/src/resolve/receivers.rs @@ -645,11 +645,16 @@ impl KernelResolver { /// buildDeclaratorRegex — `Type receiver` requiring a declarator /// terminator. The JS lookahead `(?=[;=,)\[{(]|$)` is post-checked on the /// remainder: the greedy `\s*` tail can't shrink into a passing position. + /// Qualifiers and annotations around the name are read past + /// (`Iterator* const iter`, `Arena* PROTOBUF_NULLABLE arena`, `VersionSet* + /// versions_ GUARDED_BY(mutex_);`), and the type comes back without the + /// annotations (cpp_declarator_type). pub(super) fn cpp_declarator_match(&mut self, line: &str, escaped_receiver: &str) -> Res> { let code = super::awaited::strip_ts_comments(line); let line = code.as_str(); let re = shared_regex(&format!( - r"([A-Za-z_][A-Za-z0-9_:]*(?:\s*<[^;=(){{}}]+>)?(?:\s*[*&]+)?)\s*(?-u:\b){}(?-u:\b)\s*", + r"({})\s*(?-u:\b){}(?-u:\b)(?:\s*{CPP_TRAILING_ANNOTATION})*\s*", + cpp_declarator_type_pattern(), escaped_receiver ))?; for caps in re.captures_iter(line) { @@ -661,7 +666,7 @@ impl KernelResolver { }; if ok { if let Some(m1) = caps.get(1) { - return Ok(Some(m1.as_str().to_string())); + return Ok(Some(cpp_declarator_type(m1.as_str()))); } } } @@ -1207,3 +1212,68 @@ fn go_result_from_node(node: &KNode) -> Option { literal.captures(node.signature.as_deref()?) .map(|hit| hit[1].to_string()) } + +/// cv-qualifiers and `__restrict`, before or after a declarator's `*` or `&`: `Iterator* const +/// iter`, `Slice const& key`. +const CPP_DECLARATOR_CV: &str = r"(?-u:\b)(?:const|volatile|__restrict__|__restrict)(?-u:\b)"; +/// What a pointer or reference declarator may carry after its `*` or `&`: clang's and abseil's +/// nullability annotations, and ALL-CAPS macros like protobuf's `Arena* PROTOBUF_NULLABLE arena`. +const CPP_DECLARATOR_ANNOTATION: &str = + r"(?-u:\b)(?:absl_nonnull|absl_nullable|absl_nullability_unknown|_Nonnull|_Nullable|_Null_unspecified|[A-Z][A-Z0-9]*_[A-Z0-9_]*)(?-u:\b)"; +/// A function wrapper's type, whose template argument is a function type: rocksdb's +/// `std::function create_iterator`. +pub(super) const CPP_FUNCTION_WRAPPER_TYPE: &str = + r"(?:::\s*)?(?:std\s*::\s*(?:function|move_only_function|copyable_function)|absl\s*::\s*(?:FunctionRef|AnyInvocable))\s*<[^;={}]*>"; +/// An annotation macro's call after a declared name: leveldb's `VersionSet* const versions_ +/// GUARDED_BY(mutex_);`. +const CPP_TRAILING_ANNOTATION: &str = r"(?-u:\b)[A-Z][A-Z0-9]*_[A-Z0-9_]*\s*\([^()]*\)"; + +/// A declared type as the declarator regex reads it, what it carries included: `const +/// FieldDescriptor* PROTOBUF_NULLABLE*`. +pub(super) fn cpp_declarator_type_pattern() -> &'static str { + static PATTERN: LazyLock = LazyLock::new(|| format!( + r"(?:{CPP_FUNCTION_WRAPPER_TYPE}|[A-Za-z_][A-Za-z0-9_:]*(?:\s*<[^;=(){{}}]+>)?)(?:\s*(?:{CPP_DECLARATOR_CV}|[*&]+(?:\s*{CPP_DECLARATOR_ANNOTATION})*))*" + )); + &PATTERN +} + +/// A type the declarator regex read, without the annotations it reads past after a `*` or `&`, +/// outside template arguments: `Arena* PROTOBUF_NULLABLE` is `Arena*` (cppDeclaratorType). An +/// ALL-CAPS word is an annotation only there, so a type's own name stays (`OS_HANDLE* const h`), +/// and so do its cv-qualifiers. A type with nothing to drop comes back as it is. +pub(super) fn cpp_declarator_type(text: &str) -> String { + let annotation = re!(r"^(?:absl_nonnull|absl_nullable|absl_nullability_unknown|_Nonnull|_Nullable|_Null_unspecified|[A-Z][A-Z0-9]*_[A-Z0-9_]*)$"); + let mut out = String::with_capacity(text.len()); + let mut depth = 0i32; + let mut after_pointer = false; + let mut dropped = false; + let mut prev: Option = None; + let mut rest = text; + while let Some(c) = rest.chars().next() { + let starts_word = (c.is_ascii_alphabetic() || c == '_') && !prev.is_some_and(|p| p.is_alphanumeric() || p == '_' || p == ':'); + if depth == 0 && starts_word { + let len = rest.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_')).unwrap_or(rest.len()); + let word = &rest[..len]; + if after_pointer && annotation.is_match(word) { + dropped = true; + } else { + out.push_str(word); + after_pointer = false; + } + prev = word.chars().last(); + rest = &rest[len..]; + continue; + } + match c { + '<' => depth += 1, + '>' => depth -= 1, + '*' | '&' if depth == 0 => after_pointer = true, + _ if depth == 0 && !c.is_whitespace() => after_pointer = false, + _ => (), + } + out.push(c); + prev = Some(c); + rest = &rest[c.len_utf8()..]; + } + if dropped { out.split_whitespace().collect::>().join(" ") } else { text.to_string() } +} From 72e973ae54b1fe9be5af60aa649b7041f7dcbfb9 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 03:45:45 -0600 Subject: [PATCH 19/29] fix(cpp): a call of a member template with explicit template arguments has the type the template returns (#2556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports upstream #2556. A member call written `recv->method(…)` now reaches the class's template overloads, and when each returns the same one of its own template parameters (`template const Type& GetRaw(…)`) the call's argument for it is the type: an `auto` local initialized from `factory->GetOptions()` is a `BlockBasedTableOptions*`. tree-sitter already reads a macro body's `\`-spliced lines as one statement. --- codegraph-kernel/src/resolve/cpp_range_for.rs | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/codegraph-kernel/src/resolve/cpp_range_for.rs b/codegraph-kernel/src/resolve/cpp_range_for.rs index b1a2311bda..7354d3ed16 100644 --- a/codegraph-kernel/src/resolve/cpp_range_for.rs +++ b/codegraph-kernel/src/resolve/cpp_range_for.rs @@ -49,6 +49,10 @@ pub(super) fn cpp_plain_type(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } +/// The class a member expression reads from, the member's name, and its explicit template +/// arguments (`<…>` as written). +type CppMemberRead = (Arc, String, Option); + /// A type written `auto`, `auto&`, `const auto*`. pub(super) fn is_cpp_auto(raw: &str) -> bool { type_segments(raw).is_some_and(|parts| parts.len() == 1 && parts[0] == "auto") @@ -518,7 +522,7 @@ impl KernelResolver { } // `compact_->sub_compact_states`: a data member of the object's class. "field_expression" => { - let Some((cls, field)) = self.cpp_member_of_expression(node, source, r, depth)? else { return Ok(None) }; + let Some((cls, field, None)) = self.cpp_member_of_expression(node, source, r, depth)? else { return Ok(None) }; self.cpp_data_member_written(&cls.qualified_name, &field, r) } // `new Foo(…)`: a pointer to what it constructs. @@ -549,11 +553,21 @@ impl KernelResolver { return Ok(Some(CppWritten { text: callee.to_string(), site: r.clone() })); } match function.kind() { - // `recv.method(…)`, `recv->method(…)`: the method of the receiver's class. + // `recv.method(…)`, `recv->method(…)`, `recv->method(…)`: the method of the + // receiver's class. "field_expression" => { - let Some((cls, method)) = self.cpp_member_of_expression(function, source, r, depth)? else { return Ok(None) }; + let Some((cls, method, template_arguments)) = self.cpp_member_of_expression(function, source, r, depth)? else { return Ok(None) }; let methods = self.cpp_methods_named(&cls, &method, r)?; - self.cpp_return_type_of(&methods, r) + let Some(arguments) = template_arguments else { return self.cpp_return_type_of(&methods, r) }; + // Explicit template arguments call a template, never an overload that is none. + let mut templates = Vec::new(); + for function in methods { + if !self.cpp_own_template_parameters(&function, r)?.is_empty() { templates.push(function); } + } + match self.cpp_member_template_return(&templates, &arguments, r)? { + Some(returned) => Ok(Some(returned)), + None => self.cpp_return_type_of(&templates, r), + } } // `f(…)`, `ns::f(…)`, `Class::f(…)`, `Foo(…)`: the caller's class's own or // inherited method first, then the function looked up from the caller's scope @@ -611,8 +625,16 @@ impl KernelResolver { } /// The class of the object a member expression `x.m` or `x->m` names a member of, and `m`. - fn cpp_member_of_expression(&mut self, member: TsNode, source: &Rc, r: &ResolveRefIn, depth: u32) -> Res, String)>> { - let (Some(argument), Some(field)) = (member.child_by_field_name("argument"), member.child_by_field_name("field")) else { return Ok(None) }; + /// The class of the object a member expression `recv.name`, `recv->name` or `recv->name<…>` + /// reads from, with the member's name and its explicit template arguments (`<…>` as written). + fn cpp_member_of_expression(&mut self, member: TsNode, source: &Rc, r: &ResolveRefIn, depth: u32) -> Res> { + let (Some(argument), Some(mut field)) = (member.child_by_field_name("argument"), member.child_by_field_name("field")) else { return Ok(None) }; + let mut template_arguments = None; + if field.kind() == "template_method" { + let (Some(name), Some(arguments)) = (field.child_by_field_name("name"), field.child_by_field_name("arguments")) else { return Ok(None) }; + template_arguments = Some(source.text()[arguments.byte_range()].to_string()); + field = name; + } let name = source.text()[field.byte_range()].to_string(); if !re!(r"^[A-Za-z_]\w*$").is_match(&name) { return Ok(None); } let operator = super::awaited::strip_ts_comments(&source.text()[argument.end_byte()..field.start_byte()]); @@ -626,7 +648,54 @@ impl KernelResolver { "." if !top_level_pointer(&object.text) => object, _ => return Ok(None), }; - Ok(self.cpp_written_class(&object, r)?.map(|cls| (cls, name))) + Ok(self.cpp_written_class(&object, r)?.map(|cls| (cls, name, template_arguments))) + } + + /// The names of the template parameters a C++ function template's own `template <…>` header + /// declares, in order, without their defaults: `["Type"]` for protobuf's `template const Type& GetRaw(…)`. Empty for a function that is no template. + fn cpp_own_template_parameters(&mut self, function: &Arc, r: &ResolveRefIn) -> Res> { + let Some(source) = self.read_file(&function.file_path) else { return Ok(Vec::new()) }; + let site = site_at(r, function); + let Some(tree) = self.parsed_tree(&source, &site) else { return Ok(Vec::new()) }; + let mut node = descendant_for_position(tree.root_node(), source.text(), ((function.start_line - 1).max(0) as usize, function.start_column.max(0) as usize)); + loop { + if node.kind() == "template_declaration" { break; } + if matches!(node.kind(), "field_declaration_list" | "compound_statement" | "translation_unit" | "namespace_definition") { return Ok(Vec::new()); } + let Some(parent) = node.parent() else { return Ok(Vec::new()) }; + node = parent; + } + let Some(list) = node.child_by_field_name("parameters") else { return Ok(Vec::new()) }; + Ok(named_children(list).into_iter().filter(|p| !p.kind().contains("comment")).map(|p| { + let name = p.child_by_field_name("name").or_else(|| p.child_by_field_name("declarator")) + .or_else(|| named_children(p).into_iter().rev().find(|c| matches!(c.kind(), "type_identifier" | "identifier"))); + name.map(|n| source.text()[n.byte_range()].trim_start_matches("...").trim().to_string()).unwrap_or_default() + }).collect()) + } + + /// What a call of the C++ member template whose overloads are `templates`, with the explicit + /// template arguments `arguments` (`<…>` as written), returns when every overload is declared + /// to return the same one of its own template parameters, perhaps const, a pointer or a + /// reference: that argument, so qualified, as written at the call. protobuf's `template + /// const Type& GetRaw(…)` called as `GetRaw>(…)` returns + /// a `RepeatedPtrField&`. None otherwise: then the return type is read as written. + /// + /// Port of upstream's `cppMemberTemplateReturn` (name-matcher.ts at 1753472a, #2556). + fn cpp_member_template_return(&mut self, templates: &[Arc], arguments: &str, r: &ResolveRefIn) -> Res> { + let mut slot: Option<(usize, String, String)> = None; + for function in templates { + let Some((written, _)) = self.cpp_written_return_type(function, r)? else { continue }; + let param = re!(r"(?:const|volatile)|[*&]").replace_all(&written, " ").trim().to_string(); + let index = if param.is_empty() { None } else { self.cpp_own_template_parameters(function, r)?.iter().position(|p| *p == param) }; + let Some(index) = index else { return Ok(None) }; + if slot.as_ref().is_some_and(|(seen, _, _)| *seen != index) { return Ok(None); } + slot.get_or_insert((index, written, param)); + } + let Some((index, written, param)) = slot else { return Ok(None) }; + let inner = arguments.trim().strip_prefix('<').and_then(|a| a.strip_suffix('>')).unwrap_or(""); + let Some(argument) = super::overloads_upstream::split_top_level(inner).into_iter().nth(index).map(|a| a.trim().to_string()).filter(|a| !a.is_empty()) else { return Ok(None) }; + let Ok(word) = shared_regex(&format!(r"\b{}\b", regex::escape(¶m))) else { return Ok(None) }; + Ok(Some(CppWritten { text: word.replace(&written, regex::NoExpand(&argument)).into_owned(), site: r.clone() })) } /// The declared type of the name `node` in `r`'s caller: a parameter or local (an `auto` local From 884719f68c065335b3a6b67ce74b5c4695bc024b Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 03:54:41 -0600 Subject: [PATCH 20/29] fix(cpp): a construction makes the class C++ lookup finds for the type as written Ports upstream #2542 (the construction matcher), #2549 and #2551 to the native resolver. - #2542: an `instantiates` ref is looked up from the scopes around the code, its qualifier followed a segment at a time and an alias to the class it names, at 0.9; at 0.7 when it is only the class of that name the file can see. An enum lookup finds is the enum, a function's own alias the class it names, and a template parameter or a member of a type from outside the project makes nothing. Of same-named classes the file declares, the one declared around the construction. A `<` or `>` in parentheses inside template arguments compares. - #2549: an alias spelled through a derived class (`Derived::Nested`) reaches the class a base declares, looked up a segment at a time through each class's bases. - #2551: a gtest `TEST_F` or `TEST_P` body looks up names in its fixture (bases included) right after the body itself, and a function's local class is seen only from that function. --- codegraph-kernel/src/resolve/cpp_aliases.rs | 4 + .../src/resolve/cpp_construction.rs | 274 ++++++++++++++++++ codegraph-kernel/src/resolve/cpp_types.rs | 6 +- codegraph-kernel/src/resolve/mod.rs | 1 + codegraph-kernel/src/resolve/pipeline.rs | 3 + 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 codegraph-kernel/src/resolve/cpp_construction.rs diff --git a/codegraph-kernel/src/resolve/cpp_aliases.rs b/codegraph-kernel/src/resolve/cpp_aliases.rs index 03bd9762e6..623a254427 100644 --- a/codegraph-kernel/src/resolve/cpp_aliases.rs +++ b/codegraph-kernel/src/resolve/cpp_aliases.rs @@ -11,7 +11,11 @@ pub(super) struct CppAlias { pub(super) fn type_segments(raw: &str) -> Option> { let mut text = String::new(); let mut depth = 0usize; + let mut parens = 0usize; for c in raw.chars() { + // (In template arguments, a `<` or `>` in parentheses compares: `Bits<(N > 8)>`.) + if depth > 0 && matches!(c, '(' | ')') { parens = if c == '(' { parens + 1 } else { parens.saturating_sub(1) }; continue; } + if parens > 0 { continue; } match c { '<' => depth += 1, '>' => depth = depth.saturating_sub(1), diff --git a/codegraph-kernel/src/resolve/cpp_construction.rs b/codegraph-kernel/src/resolve/cpp_construction.rs new file mode 100644 index 0000000000..74b3797bec --- /dev/null +++ b/codegraph-kernel/src/resolve/cpp_construction.rs @@ -0,0 +1,274 @@ +//! A C++ construction (`new log::Writer(…)`, `Writer w(…)`, which extraction records by the type's +//! last name) makes the class C++ lookup finds for the type as written there: from the scopes +//! around the code outwards (a gtest `TEST_F` body's fixture right after the body itself, a class +//! scope with its bases), its qualifier followed a segment at a time and an alias to the class it +//! names. leveldb's db_impl.cc writes `new log::Writer(…)` in `DBImpl` methods, which also +//! construct the `DBImpl::Writer` it defines out of line as `Writer w(…)`; by the last name alone +//! the two were one. +//! - At 0.7 when lookup doesn't place the type and the class is the only one of that name the +//! file can see. +//! - An enum lookup finds is the enum: rocksdb's `const ValueType types[] = {…}`. +//! - Through an alias the calling function declares itself, the class it names, if one of the +//! project's; else nothing. +//! - Nothing (no edge) for a template parameter, a member of a type from outside the project +//! (`stl_wrappers::KVMap::value_type`) or a function's own alias of one. +//! - Undecided, for the name strategies, when nothing the file sees is named so. +//! +//! Of several classes or aliases the file declares under one qualified name (the local classes of +//! separate gtest `TEST_F` bodies, the members of unnamed specializations), the one declared +//! around the construction. +//! +//! Port of upstream's `matchCppConstruction`, `cppEnumWritten`, `cppNamesakeAround` and +//! `cppTypeWrittenAt` (name-matcher.ts at 34dd3d2f, #2542), with the fixture scope and local-class +//! visibility of #2551 (`seenFrom`, cpp-supertypes.ts at d94a92ea). +use super::cpp_aliases::type_segments; +use super::*; + +/// What a lookup of a written type found. +enum Lookup { + /// The class, enum or alias, and whether C++ lookup placed it (rather than its being the only + /// one of that name the file can see). + Found(Arc, bool), + /// The name names no particular class of the project's. + NoClass, + /// Nothing the file sees is named so. + Unknown, +} + +fn type_kind(n: &KNode) -> bool { + matches!(n.language.as_str(), "c" | "cpp") && matches!(n.kind.as_str(), "class" | "struct" | "union" | "type_alias" | "enum") +} + +fn class_like(n: &KNode) -> bool { + matches!(n.language.as_str(), "c" | "cpp") && matches!(n.kind.as_str(), "class" | "struct" | "union") +} + +/// Whether a C or C++ file is a source file (a translation unit), whose classes only it sees. +fn source_file(file: &str) -> bool { + re!(r"(?i)\.(?:c|cc|cpp|cxx|c\+\+|cu|metal)$").is_match(file) +} + +/// The type name `text` starts with, template arguments and all: `log::Writer` of `log::Writer(…)`, +/// `Outer::Inner` of `Outer::Inner x(…)`. None when it starts with none, or a template +/// argument list goes on past the line. (A `>` in parentheses compares: `Bits<(N > 8)>`.) +fn type_written_at(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut end = 0; + loop { + let segment = if end == 0 { re!(r"^(?:::\s*)?[A-Za-z_]\w*") } else { re!(r"^\s*::\s*[A-Za-z_]\w*") }; + let Some(name) = segment.find(&text[end..]) else { return (end > 0).then(|| text[..end].to_string()) }; + end += name.end(); + let Some(open) = re!(r"^\s*<").find(&text[end..]) else { continue }; + end += open.end(); + let (mut angles, mut parens) = (1, 0); + while angles > 0 { + match bytes.get(end)? { + b'(' => parens += 1, + b')' => parens -= 1, + _ if parens > 0 => {} + b'<' => angles += 1, + b'>' => angles -= 1, + _ => {} + } + end += 1; + } + } +} + +/// One of several declarations of a class: the one in `file` itself, then in its header, then the +/// nearest by path. +fn pick(classes: Vec>, file: &str) -> Option> { + if classes.len() <= 1 { return classes.into_iter().next(); } + if let Some(own) = classes.iter().find(|n| n.file_path == file) { return Some(own.clone()); } + let stem = |p: &str| p.rsplit_once('.').filter(|(head, _)| !head.ends_with('/')).map_or(p.to_string(), |(head, _)| head.to_string()); + if let Some(header) = classes.iter().find(|n| stem(&n.file_path) == stem(file)) { return Some(header.clone()); } + let shared = |p: &str| { + let (a, b): (Vec<_>, Vec<_>) = (p.split('/').collect(), file.split('/').collect()); + a.iter().zip(&b).take(a.len().min(b.len()).saturating_sub(1)).take_while(|(x, y)| x == y).count() + }; + classes.into_iter().reduce(|best, n| if shared(&n.file_path) > shared(&best.file_path) { n } else { best }) +} + +impl KernelResolver { + /// The class (or enum, or alias) a C++ `instantiates` ref makes: Some(None) for no edge, None + /// to leave it to the name strategies. + pub(super) fn cpp_construction(&mut self, r: &ResolveRefIn) -> Res>> { + if r.language != "cpp" || r.reference_kind != "instantiates" { return Ok(None); } + let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(None) }; + let Some(source) = self.read_file(&r.file_path) else { return Ok(None) }; + let Some(line) = source.get((r.line - 1).max(0) as usize) else { return Ok(None) }; + let text = &line[super::names::js_unit_to_byte(line, r.column.max(0) as usize).min(line.len())..]; + // (A declaration's column is where its specifiers start: `const Slice key(…)`.) + let specifiers = re!(r"^(?:(?:new|const|volatile|static|thread_local|constexpr|constinit|mutable|struct|class|union|typename)\s+)*").find(text).map_or(0, |m| m.end()); + let Some(written) = type_written_at(&text[specifiers..]) else { return Ok(None) }; + let made = |node: Arc, exact: bool| KCand { node, confidence: if exact { 0.9 } else { 0.7 }, resolved_by: if exact { "qualified-name" } else { "exact-match" } }; + let Some(parts) = type_segments(&written) else { return Ok(Some(None)) }; + let global = written.trim_start().starts_with("::"); + let visible = self.cpp_construction_visible_files(&r.file_path)?; + if !global && self.cpp_alias_declaration(&written, &parts, r, 0, &visible)?.is_some_and(|(_, declared_in, _)| declared_in.id == caller.id) { + let class = match self.cpp_alias_expansion(&written, r, 0, &visible)? { + Some(super::cpp_aliases::CppAlias { raw: Some(raw), site, pointer: false, .. }) => self.cpp_type_owner_visible(&raw, &site, 0, true, &visible)?.filter(|n| class_like(n)), + _ => None, + }; + return Ok(Some(class.map(|n| made(n, true)))); + } + if !global && self.cpp_template_parameter(&caller, &parts[0], r)? { return Ok(Some(None)); } + let (node, exact) = match self.cpp_construction_lookup(&written, &parts, global, &caller, r, &visible)? { + Lookup::Found(node, exact) => (node, exact), + Lookup::NoClass => return Ok(Some(None)), + Lookup::Unknown => return Ok(None), + }; + let namesakes: Vec<_> = self.nodes_by_qualified_name(&node.qualified_name)?.iter() + .filter(|n| n.file_path == r.file_path && type_kind(n) && n.kind != "enum").cloned().collect(); + if namesakes.len() < 2 { return Ok(Some(Some(made(node, exact)))); } + Ok(self.cpp_namesake_around(&namesakes, r)?.map(|n| Some(made(n, exact)))) + } + + /// The files whose declarations a C or C++ file can see without knowing its includes: its own + /// and every header. + fn cpp_construction_visible_files(&mut self, file: &str) -> Res> { + let files = match self.sorted_files() { Some(files) => files, None => Arc::new(self.table()?.files.iter().cloned().collect()) }; + Ok(files.iter().filter(|f| *f == file || !source_file(f)).cloned().collect()) + } + + /// Whether code at `r` sees the declaration `n`: one in its own file or a header, and a class + /// local to a function only inside that function (the index names it after the function, and + /// every gtest `TEST_F` body in a namespace is `ns::TEST_F`). + fn cpp_construction_sees(&mut self, n: &KNode, r: &ResolveRefIn) -> Res { + if n.file_path != r.file_path && source_file(&n.file_path) { return Ok(false); } + let Some((scope, _)) = n.qualified_name.rsplit_once("::") else { return Ok(true) }; + let around = self.nodes_by_qualified_name(scope)?.iter().find(|f| matches!(f.kind.as_str(), "function" | "method") + && f.file_path == n.file_path && f.start_line <= n.start_line && f.end_line >= n.start_line).cloned(); + Ok(around.is_none_or(|f| f.file_path == r.file_path && f.start_line <= r.line && r.line <= f.end_line)) + } + + /// The types named `name` in the scope `scope` that code at `r` sees, else in the classes a + /// class of that name derives from, nearest first. + pub(super) fn cpp_scope_types(&mut self, scope: &str, name: &str, r: &ResolveRefIn, depth: u32) -> Res>> { + let qualified = if scope.is_empty() { name.to_string() } else { format!("{scope}::{name}") }; + let mut found = Vec::new(); + for n in self.nodes_by_qualified_name(&qualified)?.iter().filter(|n| type_kind(n)) { + if self.cpp_construction_sees(n, r)? { found.push(n.clone()); } + } + if !found.is_empty() || scope.is_empty() || depth >= 4 { return Ok(found); } + let classes: Vec<_> = self.nodes_by_qualified_name(scope)?.iter().filter(|n| class_like(n)).cloned().collect(); + for class in classes { + for base in self.supertype_nodes(&class.id)?.into_iter().filter(|n| class_like(n)) { + let inherited = self.cpp_scope_types(&base.qualified_name, name, r, depth + 1)?; + if !inherited.is_empty() { return Ok(inherited); } + } + } + Ok(Vec::new()) + } + + /// The class (or enum, or alias of a type outside the project) `written` names, looked up as + /// C++ looks it up from `caller`'s body. + fn cpp_construction_lookup(&mut self, written: &str, parts: &[String], global: bool, caller: &KNode, r: &ResolveRefIn, visible: &HashSet) -> Res { + let mut scopes: Vec = Vec::new(); + if !global && caller.kind != "file" { + let names: Vec<_> = caller.qualified_name.split("::").collect(); + scopes = (1..=names.len()).rev().map(|i| names[..i].join("::")).collect(); + if let Some(fixture) = self.cpp_gtest_fixture(caller, r)? { scopes.insert(1.min(scopes.len()), fixture); } + } + scopes.push(String::new()); + for scope in &scopes { + // Namespaces have no nodes: the first type is the shortest prefix of the path that + // names one. + let mut first = None; + for count in 1..=parts.len() { + let found = self.cpp_scope_types(scope, &parts[..count].join("::"), r, 0)?; + if let Some(found) = pick(found, &r.file_path) { first = Some((found, count)); break; } + } + let Some((mut current, consumed)) = first else { continue }; + for part in &parts[consumed..] { + match current.kind.as_str() { + "enum" => return Ok(Lookup::NoClass), + "type_alias" => match self.cpp_expand_owner(¤t, r, 0, false, visible)? { + Some(class) => current = class, + None => return Ok(Lookup::NoClass), + }, + _ => {} + } + let found = self.cpp_scope_types(¤t.qualified_name, part, r, 0)?; + let Some(next) = pick(found, &r.file_path) else { return Ok(Lookup::Unknown) }; + current = next; + } + if current.kind == "type_alias" { + if let Some(class) = self.cpp_expand_owner(¤t, r, 0, false, visible)? { current = class; } + } + return Ok(Lookup::Found(current, true)); + } + if !global { + if let Some(found) = self.cpp_type_via_using(written, parts, r, visible)?.filter(|n| class_like(n)) { return Ok(Lookup::Found(found, true)); } + } + // The only class of that name the file can see, by its qualified tail. + let path = parts.join("::"); + let mut tail = Vec::new(); + for n in self.nodes_by_name(parts.last().unwrap())?.iter().filter(|n| class_like(n) && (n.qualified_name == path || n.qualified_name.ends_with(&format!("::{path}")))) { + if self.cpp_construction_sees(n, r)? { tail.push(n.clone()); } + } + let own: Vec<_> = tail.iter().filter(|n| n.file_path == r.file_path).cloned().collect(); + let sole = if own.is_empty() { tail } else { own }; + Ok(match sole.as_slice() { [only] => Lookup::Found(only.clone(), false), _ => Lookup::Unknown }) + } + + /// The type `raw` names, looked up from the scopes around `r`'s caller a segment at a time, + /// each class's bases included: protobuf's `using ConformanceRequestSetting = + /// BinaryAndJsonConformanceSuite::ConformanceRequestSetting;` names a class the suite inherits + /// from `ConformanceTestSuite`. (Port of upstream's #2549.) + pub(super) fn cpp_type_through_bases(&mut self, raw: &str, r: &ResolveRefIn) -> Res>> { + let Some(parts) = type_segments(raw) else { return Ok(None) }; + let mut scopes: Vec = Vec::new(); + if !raw.trim_start().starts_with("::") { + if let Some(caller) = self.node_by_id(&r.from_node_id)? { + let mut names: Vec<_> = caller.qualified_name.split("::").map(str::to_string).collect(); + if is_inheritance_ref(&r.reference_kind) { names.pop(); } + scopes = (1..=names.len()).rev().map(|i| names[..i].join("::")).collect(); + } + } + scopes.push(String::new()); + for scope in &scopes { + let mut first = None; + for count in 1..=parts.len() { + let found = self.cpp_scope_types(scope, &parts[..count].join("::"), r, 0)?; + if let Some(found) = pick(found, &r.file_path) { first = Some((found, count)); break; } + } + let Some((mut current, consumed)) = first else { continue }; + for part in &parts[consumed..] { + if !class_like(¤t) { return Ok(None); } + let Some(next) = pick(self.cpp_scope_types(¤t.qualified_name, part, r, 0)?, &r.file_path) else { return Ok(None) }; + current = next; + } + return Ok(Some(current)); + } + Ok(None) + } + + /// Of several classes or aliases `r`'s file declares under one qualified name, the one whose + /// holder (the function or class it is declared in, else the file) holds `r`. None unless + /// exactly one is. + fn cpp_namesake_around(&mut self, namesakes: &[Arc], r: &ResolveRefIn) -> Res>> { + let nodes = self.nodes_in_file(&r.file_path)?; + let holders: Vec<_> = nodes.iter().filter(|n| matches!(n.kind.as_str(), "function" | "method" | "class" | "struct" | "union")).collect(); + let around: Vec<_> = namesakes.iter().filter(|cls| { + let holder = holders.iter().filter(|n| n.id != cls.id && n.start_line <= cls.start_line && n.end_line >= cls.end_line) + .min_by_key(|n| n.end_line - n.start_line); + holder.is_none_or(|h| h.start_line <= r.line && r.line <= h.end_line) + }).cloned().collect(); + Ok(match around.as_slice() { [only] => Some(only.clone()), _ => None }) + } +} + +#[cfg(test)] +mod tests { + use super::type_written_at; + + #[test] + fn reads_the_type_a_construction_writes() { + assert_eq!(type_written_at("log::Writer(&file);").as_deref(), Some("log::Writer")); + assert_eq!(type_written_at("Outer<(sizeof(int) > 2)>::Inner();").as_deref(), Some("Outer<(sizeof(int) > 2)>::Inner")); + assert_eq!(type_written_at("Map> m;").as_deref(), Some("Map>")); + assert_eq!(type_written_at("Map, r: &ResolveRefIn, depth: u32, constructor: bool, included: &HashSet) -> Res>> { + pub(super) fn cpp_expand_owner(&mut self, owner: &Arc, r: &ResolveRefIn, depth: u32, constructor: bool, included: &HashSet) -> Res>> { if owner.kind != "type_alias" { return Ok((owner.kind != "enum").then(|| owner.clone())); } let Some(source) = self.read_file(&owner.file_path) else { return Ok(None) }; let mut lines = source.get((owner.start_line - 1).max(0) as usize..owner.end_line.max(0) as usize).unwrap_or(&[]).to_vec(); diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 4eaf20043b..8c6219f28f 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -582,6 +582,7 @@ mod cpp_range_for; mod cpp_implicit_this; mod cpp_namespace; mod cpp_annotations; +mod cpp_construction; mod cpp_overloads; mod cpp_translation_unit; mod inherited_method; diff --git a/codegraph-kernel/src/resolve/pipeline.rs b/codegraph-kernel/src/resolve/pipeline.rs index 24192d7124..3c958eeb25 100644 --- a/codegraph-kernel/src/resolve/pipeline.rs +++ b/codegraph-kernel/src/resolve/pipeline.rs @@ -776,6 +776,9 @@ if super::lang_scope::is_dart_member_read(r) { /// The per-ref pipeline: the Rust `::`-path arm, then one route. pub(super) fn resolve_ref(&mut self, r: &ResolveRefIn) -> Res { + if let Some(result) = self.cpp_construction(r)? { + return match result { Some(candidate) => self.finish_pre_framework(r, candidate), None => Ok(self.refused()) }; + } if let Some(result) = self.cpp_plain_call(r)? { return match result { Some(candidate) => self.finish_pre_framework(r, candidate), None => Ok(self.refused()) }; } From b9510f0e6c4d23f6fed5a20e4b35a44ca70a5703 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 04:01:57 -0600 Subject: [PATCH 21/29] fix(cpp): read a receiver declared as a pointer to a class template over two lines (#2555) Ports upstream #2555's remaining case. `Table` on one line and `*table_ptr = nullptr;` on the next declare a pointer to a class template, whose `->` the fork read only for a loop's element; any such pointer now reaches the template's members, and a library template's (`std::`, `absl::`) still reach no project method. --- codegraph-kernel/src/resolve/cpp_receivers.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/codegraph-kernel/src/resolve/cpp_receivers.rs b/codegraph-kernel/src/resolve/cpp_receivers.rs index c892ae4839..62f698ca2f 100644 --- a/codegraph-kernel/src/resolve/cpp_receivers.rs +++ b/codegraph-kernel/src/resolve/cpp_receivers.rs @@ -105,8 +105,13 @@ impl KernelResolver { let ty = match operator.trim() { "." if pointers == 0 => raw.trim_end_matches('&').trim().to_string(), "->" if pointers == 1 && !raw.contains('<') => raw.replace(['*', '&'], "").trim().to_string(), - // An element that points at a class template (`const autovector*`). - "->" if pointers == 1 && element => super::cpp_range_for::cpp_pointee(&raw).unwrap_or_default(), + // A pointer to a class template (`const autovector*`, a `Table` + // pointer declared over two lines); a library template's members are no project method. + "->" if pointers == 1 => { + let pointee = super::cpp_range_for::cpp_pointee(&raw).unwrap_or_default(); + if re!(r"^(?:const\s+)?(?:::)?(?:std|absl)::").is_match(pointee.trim()) { return Ok(Some(None)); } + pointee + } // A standard smart pointer or optional: `->` reaches a member of the type it holds. // So does an `absl::StatusOr`, `absl::optional` or `absl::NoDestructor` of the library's. "->" if pointers == 0 => { From f92479d405b10b75429ed800e9505d6376d1e5fa Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 04:01:57 -0600 Subject: [PATCH 22/29] fix(cpp): blank block comments and string contents before reading declarations line by line (#2552) Ports upstream #2552. The line-at-a-time C++ declaration scans read the text of string literals, so protobuf's `[[deprecated("Please use code() instead")]]` and rocksdb's `"Unknown code(%d): "` declared a local `code` that shadowed the method. Every scan now reads the file with comments and the contents of string and character literals blanked (a `'` between digits separates them). Lines starting with `*` were already read as code. --- codegraph-kernel/src/resolve/cpp_annotations.rs | 12 +++++++++--- codegraph-kernel/src/resolve/mod.rs | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/codegraph-kernel/src/resolve/cpp_annotations.rs b/codegraph-kernel/src/resolve/cpp_annotations.rs index 4a078e50ae..c5f41db3f7 100644 --- a/codegraph-kernel/src/resolve/cpp_annotations.rs +++ b/codegraph-kernel/src/resolve/cpp_annotations.rs @@ -13,9 +13,9 @@ use std::sync::LazyLock; use regex::Regex; -/// `text` with comments and the contents of string and character literals replaced by spaces, -/// byte for byte (line breaks kept). -fn masked_code(text: &str) -> Vec { +/// `text` with comments and the contents of string and character literals (raw strings included) +/// replaced by spaces, byte for byte (line breaks kept). +pub(super) fn masked_code(text: &str) -> Vec { let bytes = text.as_bytes(); let mut out = bytes.to_vec(); let blank = |out: &mut Vec, from: usize, to: usize| { @@ -42,6 +42,8 @@ fn masked_code(text: &str) -> Vec { blank(&mut out, open, end); i = end; } + // (A `'` after a digit or letter separates digits: `1'000'000`.) + b'\'' if i > 0 && bytes[i - 1].is_ascii_alphanumeric() => i += 1, quote @ (b'"' | b'\'') => { let mut j = i + 1; while j < bytes.len() && bytes[j] != quote && bytes[j] != b'\n' { @@ -197,6 +199,10 @@ mod tests { #[test] fn blanks_declaration_annotations_only() { + let input = "int n = 1'000; // a \"b\"\nf(\"x(\", 'y'); /* z\n w */ g();"; + let code = String::from_utf8(super::masked_code(input)).unwrap(); + assert_eq!(code.len(), input.len()); + assert_eq!(code.split('\n').map(str::trim_end).collect::>(), ["int n = 1'000;", "f(\" \", ' ');", " g();"]); let text = "Printer* PROTOBUF_NONNULL printer_;\nVersionSet* const versions_ GUARDED_BY(mutex_);\nint n = a * MAX_LEN + 1;\nFoo DEFAULT_OPTIONS;\nreturn x * FOO_BAR;\n// Foo* PROTOBUF_NONNULL c;\n"; let out = cpp_without_annotations(text); assert_eq!(out.len(), text.len()); diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 8c6219f28f..30444af9cb 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -425,7 +425,9 @@ impl SourceFile { /// C++ lines with raw literals and comments masked, shared by receiver lookups. pub(super) fn cpp_code_lines(&self) -> &[String] { - self.cpp_code_lines.get_or_init(|| awaited::strip_ts_comments(&cpp::mask_cpp_raw_strings(self.text())).split('\n').map(str::to_string).collect()) + // (Block comments and string contents are blanked too: protobuf's `[[deprecated("Please use + // code() instead")]]` and rocksdb's `"Unknown code(%d): "` declare no local.) + self.cpp_code_lines.get_or_init(|| String::from_utf8_lossy(&cpp_annotations::masked_code(&cpp::mask_cpp_raw_strings(self.text()))).split('\n').map(str::to_string).collect()) } /// The file's Python statements and the scopes they run in From 7e5e1babcdc13467ec1dccdd76f06657d27253a2 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 04:04:36 -0600 Subject: [PATCH 23/29] test(cpp): keep the fork's no-guess result for receivers whose type can't be read Upstream #2537 rewrote the 'parameter of a method the class declares' fixture without its locals, and #2548 expects `arena.AllocateAligned` on an untyped `auto` local to reach `Arena::AllocateAligned` at 0.8. In both the receiver's type can't be read, and upstream guesses the class from the receiver's name; the fork leaves such calls unlinked, as README's 'About this fork' records. The assertions now state the fork's result, with that reason beside them. --- __tests__/cpp-class-name-receiver-members.test.ts | 6 +++++- __tests__/cpp-member-receiver.test.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/__tests__/cpp-class-name-receiver-members.test.ts b/__tests__/cpp-class-name-receiver-members.test.ts index 29418db9cc..b44600631f 100644 --- a/__tests__/cpp-class-name-receiver-members.test.ts +++ b/__tests__/cpp-class-name-receiver-members.test.ts @@ -81,7 +81,11 @@ describe('C++ calls on a receiver named like a class', () => { }); try { expect(callsAt(cg, 'google::protobuf::Run', 6).filter((t) => /UniquePtr|ThreadSafeArena/.test(t) && t.endsWith('@0.8'))).toEqual([]); - expect(callsAt(cg, 'google::protobuf::Run', 7)).toEqual(['google::protobuf::Arena::AllocateAligned @0.8']); + // `arena` is an `auto` local whose initializer's type can't be read (`MakeArena` is + // declared nowhere). The fork's resolver leaves such a call unlinked rather than + // guessing the class from the receiver's name, as upstream does at 0.8 (README, + // "About this fork": C++ calls on an `auto` local). + expect(callsAt(cg, 'google::protobuf::Run', 7)).toEqual([]); } finally { cg.close(); } diff --git a/__tests__/cpp-member-receiver.test.ts b/__tests__/cpp-member-receiver.test.ts index 9a32135a92..b4650115d9 100644 --- a/__tests__/cpp-member-receiver.test.ts +++ b/__tests__/cpp-member-receiver.test.ts @@ -420,13 +420,13 @@ describe('what keeps the call as it was', () => { ), }); try { - // The fork reads the local's declared type from the syntax tree, so these are typed calls - // (upstream's line scan cannot read `const ColumnFamilySet* const` and guesses, at 0.7). - expect(calls(cg, 'rocksdb::DBImpl::CountColumnFamilies').filter((c) => c.includes('NumberOfColumnFamilies'))).toEqual([ - 'rocksdb::ColumnFamilySet::NumberOfColumnFamilies @0.9', - ]); + // Neither name is a member, so neither call is typed through one. Upstream then guesses + // the class from the receiver's name (ColumnFamilySet::NumberOfColumnFamilies and + // Statistics::load, at 0.7); the fork's resolver leaves a receiver whose type it can't + // read unlinked instead (README, "About this fork": C++ calls on an `auto` local, ...). + expect(calls(cg, 'rocksdb::DBImpl::CountColumnFamilies').filter((c) => c.includes('NumberOfColumnFamilies'))).toEqual([]); // Nor is a method the class declares (`stats()`) a member of its name. - expect(calls(cg, 'rocksdb::DBImpl::CountColumnFamilies').filter((c) => c.includes('load'))).toEqual(['rocksdb::Statistics::load @0.9']); + expect(calls(cg, 'rocksdb::DBImpl::CountColumnFamilies').filter((c) => c.includes('load'))).toEqual([]); } finally { cg.close(); } From b5d1647604d0b0a8d296861fbd88f85a369d1c36 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 04:06:39 -0600 Subject: [PATCH 24/29] test(golden): re-baseline for C++ constructions placed by lookup Eleven C++ instantiates edges in torture-multilang keep their targets; the construction matcher (#2542) now places them by C++ lookup, so they read 0.9 qualified-name where name matching gave 0.4 or 0.9 exact-match. No edge is added or removed. --- .../fixtures/golden/torture-multilang.dump | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/__tests__/fixtures/golden/torture-multilang.dump b/__tests__/fixtures/golden/torture-multilang.dump index 97524d883c..9de005b74e 100644 --- a/__tests__/fixtures/golden/torture-multilang.dump +++ b/__tests__/fixtures/golden/torture-multilang.dump @@ -3695,7 +3695,7 @@ {"source":"function:92e7a37a37fcdd1233f2060dcaa08d46","target":"function:510392b0722e8898090560466c5a5ec5","kind":"references","metadata":"{\"confidence\":0.95,\"resolvedBy\":\"function-ref\",\"refName\":\"handler\",\"refKind\":\"function_ref\",\"fnRef\":true}","line":100,"col":9,"provenance":null} {"source":"function:92e7a37a37fcdd1233f2060dcaa08d46","target":"function:5856f8137240d29ffd5ec6ced63378bc","kind":"references","metadata":"{\"confidence\":0.95,\"resolvedBy\":\"function-ref\",\"refName\":\"cbB\",\"refKind\":\"function_ref\",\"fnRef\":true}","line":99,"col":18,"provenance":null} {"source":"function:92e7a37a37fcdd1233f2060dcaa08d46","target":"method:84b1066798bf4890531cf6d4cd0b61b2","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"make\"}","line":93,"col":2,"provenance":null} -{"source":"function:9374676602bba21f257767ff6086419a","target":"class:0083dc18baa2b629e8572db20ba2b464","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":133,"col":31,"provenance":null} +{"source":"function:9374676602bba21f257767ff6086419a","target":"class:0083dc18baa2b629e8572db20ba2b464","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":133,"col":31,"provenance":null} {"source":"function:973ef3de44975d37a59ae5993585dcb8","target":"enum:fa2b2411f27fe00e081bceff1312896a","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Flag\"}","line":31,"col":13,"provenance":null} {"source":"function:973ef3de44975d37a59ae5993585dcb8","target":"enum:fa2b2411f27fe00e081bceff1312896a","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Flag\"}","line":32,"col":12,"provenance":null} {"source":"function:975ff1961c427064617a254938d60cd1","target":"enum:fcb9d383c69413c946db24cec4719f95","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"ColorT\"}","line":217,"col":12,"provenance":null} @@ -3715,8 +3715,8 @@ {"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"function:9374676602bba21f257767ff6086419a","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"make_widget\"}","line":142,"col":2,"provenance":null} {"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"method:35a476809e2727e80d7f2c8c404180b2","kind":"calls","metadata":"{\"confidence\":0.85,\"resolvedBy\":\"instance-method\",\"refName\":\"make_widget().Tick\"}","line":142,"col":2,"provenance":null} {"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"method:8ce5cf7b5413599d7a690c53b25e19f4","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"function-ref\",\"refName\":\"app::Config::apply\",\"refKind\":\"function_ref\",\"fnRef\":true}","line":153,"col":13,"provenance":null} -{"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"struct:08acded06d18f155491d1e66e44ba80b","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Vec2\"}","line":138,"col":2,"provenance":null} -{"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"struct:08acded06d18f155491d1e66e44ba80b","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Vec2\"}","line":140,"col":2,"provenance":null} +{"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"struct:08acded06d18f155491d1e66e44ba80b","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Vec2\"}","line":138,"col":2,"provenance":null} +{"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"struct:08acded06d18f155491d1e66e44ba80b","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Vec2\"}","line":140,"col":2,"provenance":null} {"source":"function:a17e01edc91eda2d9ff83f0051193fa3","target":"variable:6bc194c6a17ac46928d714fb50070e52","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"GlobalConfig\"}","line":148,"col":14,"provenance":null} {"source":"function:a1998a5fcada0a95b1aebb30d3f1222c","target":"class:03853e00aca8d3407b8504dc2c5cdfe8","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"FactoryT\"}","line":77,"col":2,"provenance":null} {"source":"function:a1998a5fcada0a95b1aebb30d3f1222c","target":"class:e1f5ba7cde2f24dbd5d8d72eeba2b2d6","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"ConfigT\"}","line":70,"col":2,"provenance":null} @@ -3747,11 +3747,11 @@ {"source":"function:ae77b9f848fb3b2cb309ca8e5667e81b","target":"function:0499cc5aef87518260d547d716f829ce","kind":"calls","metadata":"{\"synthesizedBy\":\"rtk-query\",\"via\":\"getWidget\",\"registeredAt\":\"torture.tsx:184\"}","line":184,"col":null,"provenance":"heuristic"} {"source":"function:b06c2d27fa49d5e97af860ffe032fbdd","target":"class:fa355652331eee519e97b9ef15a61b2d","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"WidgetT\"}","line":49,"col":34,"provenance":null} {"source":"function:b06c2d27fa49d5e97af860ffe032fbdd","target":"class:fa355652331eee519e97b9ef15a61b2d","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"WidgetT\"}","line":49,"col":5,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":40,"col":2,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":41,"col":2,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":42,"col":2,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":43,"col":2,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":44,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":40,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":41,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":42,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":43,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":44,"col":2,"provenance":null} {"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"constant:f91b4dd2faf508ed32bf113beb9f745e","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"method:36b2c35b96abd233c6449d02d9c4ef2f","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Box::Box/1\"}","line":45,"col":2,"provenance":null} {"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"method:7e714128b5b3c81a4e4f571a142197ba","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget::Widget/0\"}","line":39,"col":2,"provenance":null} @@ -3760,13 +3760,13 @@ {"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"method:7e714128b5b3c81a4e4f571a142197ba","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":46,"col":2,"provenance":null} {"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"method:f73f31bd6cc45134433935ad6eaae9f9","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget::Widget/2\"}","line":42,"col":2,"provenance":null} {"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"method:f73f31bd6cc45134433935ad6eaae9f9","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget::Widget/2\"}","line":43,"col":2,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"struct:dc1fb066a6c08cc705216a045fa93a24","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Box\"}","line":45,"col":2,"provenance":null} -{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"struct:dcb8897c9696246c318260fb9188d7fb","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Aggregate\"}","line":38,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"struct:dc1fb066a6c08cc705216a045fa93a24","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Box\"}","line":45,"col":2,"provenance":null} +{"source":"function:b3507638f9afee3fde16cc943fd670cf","target":"struct:dcb8897c9696246c318260fb9188d7fb","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Aggregate\"}","line":38,"col":2,"provenance":null} {"source":"function:b8275c2cd511278505b4f6a60897df9c","target":"struct:ab1ddae19b44311cc03a2493e898f4db","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"function:bb0cdf86bfb5dc9391a3d5fca445ebc2","target":"function:bb0cdf86bfb5dc9391a3d5fca445ebc2","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"tailer\"}","line":40,"col":53,"provenance":null} {"source":"function:bcafa05abfafe5a4b4bc5c8b3f7e3e0d","target":"class:fa355652331eee519e97b9ef15a61b2d","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"WidgetT\"}","line":50,"col":0,"provenance":null} {"source":"function:c062ca02de706ffe85e7dfb2f9586331","target":"class:c2dfee99ca5361d8de7ae7de6858d0bf","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"WidgetK\",\"refKind\":\"calls\"}","line":13,"col":11,"provenance":null} -{"source":"function:c2971e4b1912de92c9c1e02416630c6b","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.4,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":59,"col":2,"provenance":null} +{"source":"function:c2971e4b1912de92c9c1e02416630c6b","target":"class:185f6260a8d5b738c53be0accb2a4e19","kind":"instantiates","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget\"}","line":59,"col":2,"provenance":null} {"source":"function:c2971e4b1912de92c9c1e02416630c6b","target":"method:7e714128b5b3c81a4e4f571a142197ba","kind":"calls","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"qualified-name\",\"refName\":\"Widget::Widget/0\"}","line":59,"col":2,"provenance":null} {"source":"function:c5b82c471b23eb80ba82537084522458","target":"struct:0c309a8ed6f33456724d94ba73599e04","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Widget\"}","line":200,"col":34,"provenance":null} {"source":"function:c97d0d5969167c7958098f13defe9e08","target":"function:89b5279cd19a084e8974e1c0985c27d9","kind":"calls","metadata":"{\"confidence\":0.5,\"resolvedBy\":\"exact-match\",\"refName\":\"fetchUser\"}","line":61,"col":23,"provenance":null} From 9d5b66dc145907eb96ac3bab996086755bd38468 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 04:32:09 -0600 Subject: [PATCH 25/29] fix(cpp): place a construction in a test tree's header only from that tree or by including it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nlohmann/json keeps an old release of the library in tests/abi/include/nlohmann/json_v3_10_5.hpp. It spells `namespace nlohmann {` out, while the library's headers open the namespace with a macro, so only the old copy's types are indexed under `nlohmann::`. Construction lookup treated every header as visible, so the docs examples' and benchmarks' `json j(…)` and `json::json_pointer p(…)`, through `using json = nlohmann::json;`, reached the old copy: 233 edges on the json corpus. A header of a test tree (a test directory's child, such as `tests/abi/`) is now seen only from inside that tree or through the file's includes. A member of an alias the lookup can't follow now gets no edge only when the alias names a standard type (`using Bytes = std::string;`). Otherwise, when the project declares a class of the member's name, it is left to the name strategies, which place `json::json_pointer` in the library's header as before. --- ...cpp-construction-test-tree-headers.test.ts | 112 ++++++++++++++++++ .../src/resolve/cpp_construction.rs | 57 ++++++++- 2 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 __tests__/cpp-construction-test-tree-headers.test.ts diff --git a/__tests__/cpp-construction-test-tree-headers.test.ts b/__tests__/cpp-construction-test-tree-headers.test.ts new file mode 100644 index 0000000000..9b3751260f --- /dev/null +++ b/__tests__/cpp-construction-test-tree-headers.test.ts @@ -0,0 +1,112 @@ +/** + * A C++ construction is placed by lookup over the headers its file can see. + * nlohmann/json keeps an old release of the whole library in + * `tests/abi/include/nlohmann/json_v3_10_5.hpp`, which spells `namespace + * nlohmann {` out, while the library's own headers open it with a macro, so + * only the old copy's types are indexed under `nlohmann::`. The docs + * examples' `json::json_pointer p("/")`, through `using json = + * nlohmann::json;`, reached that copy. A header of a test tree is now seen + * only from inside that tree or by including it, and an alias lookup can't + * follow leaves a member the project declares a class for to the name + * strategies. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-test-tree-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** The files of what a function's `instantiates` edges reach, as `qualifiedName in file`. */ +function made(cg: CodeGraph, qualifiedName: string): string[] { + const from = cg.getNodesByKind('function').find((n) => n.qualifiedName === qualifiedName && !n.filePath.includes('include'))!; + return cg.getOutgoingEdgesFrom([from.id], ['instantiates']) + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.qualifiedName} in ${n.filePath}`) + .sort(); +} + +const LIBRARY = lines( + '#define LIB_BEGIN namespace lib {', + '#define LIB_END }', + 'LIB_BEGIN', + 'class json_pointer { public: json_pointer(const char* s) {} };', + 'class basic_json { public: using json_pointer = ::lib::json_pointer; basic_json(int v) {} };', + 'using json = basic_json;', + 'LIB_END', +); + +const OLD_RELEASE = lines( + 'namespace lib {', + 'class json_pointer { public: json_pointer(const char* s) {} };', + 'class basic_json { public: using json_pointer = ::lib::json_pointer; basic_json(int v) {} };', + 'using json = basic_json;', + '} // namespace lib', +); + +const files = { + // Two copies of the library, so `` resolves to neither. + 'include/lib/json.hpp': LIBRARY, + 'single_include/lib/json.hpp': LIBRARY, + 'tests/abi/include/lib/json_v1.hpp': OLD_RELEASE, +}; + +describe('C++ constructions and the headers of a test tree', () => { + it('are not placed in a test tree code outside it does not include', async () => { + const cg = await indexed({ + ...files, + 'docs/examples/pointer.cpp': lines( + '#include ', + 'using json = lib::json;', + 'int main() {', + ' json::json_pointer p("/");', + ' json j(1);', + ' return 0;', + '}', + ), + 'tests/benchmarks/bench.cpp': lines( + '#include ', + 'using json = lib::json;', + 'int bench() {', + ' json j(1);', + ' return 0;', + '}', + ), + }); + const examples = made(cg, 'main'); + expect(examples.some((t) => t.includes('tests/abi/'))).toBe(false); + expect(examples.some((t) => t.startsWith('json_pointer in '))).toBe(true); + expect(made(cg, 'bench')).toEqual(['json in tests/benchmarks/bench.cpp']); + cg.close(); + }); + + it('are placed there from inside the tree', async () => { + const cg = await indexed({ + ...files, + 'tests/abi/abi.cpp': lines( + 'int abi() {', + ' lib::json_pointer p("/");', + ' return 0;', + '}', + ), + }); + expect(made(cg, 'abi')).toEqual(['lib::json_pointer in tests/abi/include/lib/json_v1.hpp']); + cg.close(); + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_construction.rs b/codegraph-kernel/src/resolve/cpp_construction.rs index 74b3797bec..aee523898f 100644 --- a/codegraph-kernel/src/resolve/cpp_construction.rs +++ b/codegraph-kernel/src/resolve/cpp_construction.rs @@ -11,7 +11,10 @@ //! - Through an alias the calling function declares itself, the class it names, if one of the //! project's; else nothing. //! - Nothing (no edge) for a template parameter, a member of a type from outside the project -//! (`stl_wrappers::KVMap::value_type`) or a function's own alias of one. +//! (`stl_wrappers::KVMap::value_type`) or a function's own alias of one. A member of an alias +//! lookup can't follow that names no standard type is left undecided when the project +//! declares a class of the member's name. +//! - A header of a test tree is seen only from inside that tree or by including it. //! - Undecided, for the name strategies, when nothing the file sees is named so. //! //! Of several classes or aliases the file declares under one qualified name (the local classes of @@ -125,17 +128,38 @@ impl KernelResolver { } /// The files whose declarations a C or C++ file can see without knowing its includes: its own - /// and every header. + /// and every header, but for a header of a test tree, which code outside one sees only by + /// including it. fn cpp_construction_visible_files(&mut self, file: &str) -> Res> { let files = match self.sorted_files() { Some(files) => files, None => Arc::new(self.table()?.files.iter().cloned().collect()) }; - Ok(files.iter().filter(|f| *f == file || !source_file(f)).cloned().collect()) + let mut visible = HashSet::new(); + for f in files.iter().filter(|f| *f == file || !source_file(f)) { + if !self.cpp_unincluded_test_header(f, file)? { visible.insert(f.clone()); } + } + Ok(visible) + } + + /// Whether `header` is a header of a test tree (a test directory's child: `tests/abi/`) that + /// `file`, outside that tree, doesn't include: nlohmann/json's examples and benchmarks + /// construct `json::json_pointer` and `json` with the library's headers, not with the old + /// release `tests/abi/include/nlohmann/json_v3_10_5.hpp` keeps. + fn cpp_unincluded_test_header(&mut self, header: &str, file: &str) -> Res { + if header == file || !is_test_path(header) { return Ok(false); } + let dirs: Vec<_> = header.split('/').collect(); + let dirs = &dirs[..dirs.len() - 1]; + let Some(at) = (1..=dirs.len()).find(|&i| is_test_path(&format!("{}/x", dirs[..i].join("/")))) else { return Ok(false) }; + let root = format!("{}/", dirs[..(at + 1).min(dirs.len())].join("/")); + if file.starts_with(&root) { return Ok(false); } + Ok(!self.namespace_visible_files(file, "cpp")?.contains(header)) } - /// Whether code at `r` sees the declaration `n`: one in its own file or a header, and a class - /// local to a function only inside that function (the index names it after the function, and - /// every gtest `TEST_F` body in a namespace is `ns::TEST_F`). + /// Whether code at `r` sees the declaration `n`: one in its own file or a header (of a test + /// tree only from a test or by including it), and a class local to a function only inside + /// that function (the index names it after the function, and every gtest `TEST_F` body in a + /// namespace is `ns::TEST_F`). fn cpp_construction_sees(&mut self, n: &KNode, r: &ResolveRefIn) -> Res { if n.file_path != r.file_path && source_file(&n.file_path) { return Ok(false); } + if self.cpp_unincluded_test_header(&n.file_path, &r.file_path)? { return Ok(false); } let Some((scope, _)) = n.qualified_name.rsplit_once("::") else { return Ok(true) }; let around = self.nodes_by_qualified_name(scope)?.iter().find(|f| matches!(f.kind.as_str(), "function" | "method") && f.file_path == n.file_path && f.start_line <= n.start_line && f.end_line >= n.start_line).cloned(); @@ -185,6 +209,11 @@ impl KernelResolver { "enum" => return Ok(Lookup::NoClass), "type_alias" => match self.cpp_expand_owner(¤t, r, 0, false, visible)? { Some(class) => current = class, + // An alias of a standard type (`using Bytes = std::string;`) has no + // member of the project's. Another the lookup can't follow may lead to a + // class of the member's name through what it doesn't read: nlohmann/json's + // `using json = nlohmann::json;` names a namespace a macro opens. + None if !self.cpp_alias_of_std(¤t) && self.cpp_project_type_named(part, r)? => return Ok(Lookup::Unknown), None => return Ok(Lookup::NoClass), }, _ => {} @@ -212,6 +241,22 @@ impl KernelResolver { Ok(match sole.as_slice() { [only] => Lookup::Found(only.clone(), false), _ => Lookup::Unknown }) } + /// Whether the alias `alias` names a type of the standard library: `using Bytes = std::string;`, + /// `typedef std::map Counts;`. + fn cpp_alias_of_std(&mut self, alias: &KNode) -> bool { + let Some(source) = self.read_file(&alias.file_path) else { return false }; + let text = source.get((alias.start_line - 1).max(0) as usize..alias.end_line.max(0) as usize).unwrap_or(&[]).join(" "); + re!(r"(?:=|\btypedef)\s*(?:typename\s+)?(?:::)?std\s*::").is_match(&text) + } + + /// Whether a class the file at `r` can see is named `name`. + fn cpp_project_type_named(&mut self, name: &str, r: &ResolveRefIn) -> Res { + for n in self.nodes_by_name(name)?.iter().filter(|n| class_like(n)) { + if self.cpp_construction_sees(n, r)? { return Ok(true); } + } + Ok(false) + } + /// The type `raw` names, looked up from the scopes around `r`'s caller a segment at a time, /// each class's bases included: protobuf's `using ConformanceRequestSetting = /// BinaryAndJsonConformanceSuite::ConformanceRequestSetting;` names a class the suite inherits From c876f90604332ae4d4a683f67c324eafc4b08a38 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 04:36:42 -0600 Subject: [PATCH 26/29] docs: record where the fork's C++ resolution differs from upstream's in this merge --- CHANGELOG.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a045122e..83779a63b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -492,6 +492,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a variable or parameter declared over more than one line, with its type begun on a line above or alone on the line above its name, now has its type read, so calls on it link to that class's methods: protocolbuffers/protobuf's `extension.number()` on a `const google::protobuf::internal::ExtensionIdentifier<` … `>& extension` parameter reaches protobuf's `ExtensionIdentifier`, not hpb's, and facebook/rocksdb's `cf_sv_pairs.size()` on an `autovector<…>` written above `cf_sv_pairs;` reaches `autovector`. Calls on a standard-library container declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a variable initialized from a call of a member function template with explicit template arguments now has the type that template returns for them, so calls on it link to that class's methods: protocolbuffers/protobuf's `const auto& rep = reflection->GetRaw>(…)` is a `RepeatedPtrField`, and facebook/rocksdb's `auto bbto = factory->GetOptions()` a `BlockBasedTableOptions`. Re-index C++ projects after upgrading. - In this fork, the two C++ changes above also run in the native resolver. A call on a variable, member or chain whose type it cannot read, such as one a template parameter decides (`T*`, `std::shared_ptr`), an alias of a trait of a parameter, or the result of a function template whose return type is its parameter, stays unlinked rather than being guessed by the receiver's name, and a chain of up to four calls (`cfd->current()->storage_info()->LevelFiles()`) is read from its declarations, not only its first inner call; a longer one is looked up by the last name, as before. A chained call spelled with `.` on a returned `std::unique_ptr` or `std::optional` is that type's own member, not a method of what it holds (`->` still reaches that); and a variable declared in the initializer of a `for`, `if` or `switch` statement is seen before a same-named member the class inherits. +- In this fork, the C++ changes above differ from upstream's in two more ways. A call whose receiver's type can't be read, like a call on an `auto` local whose initializer names a function declared nowhere, or on a name that is neither a member nor a local the method declares, stays unlinked; upstream guesses the class from the receiver's name. And object creation sees a header under a test directory, like nlohmann/json's `tests/abi/include/nlohmann/json_v3_10_5.hpp`, an old release of the library, only from inside that directory's tree or through an `#include`. That copy spells out `namespace nlohmann {`, which the library's own headers open with a macro, so the docs examples' and benchmarks' `json j(…)` and `json::json_pointer p(…)` had linked to its classes, 233 times on nlohmann/json. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. diff --git a/README.md b/README.md index 80510145dc..0f5a36266d 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Call resolution the fork adds: - C++ `->` calls on a `std::unique_ptr`, `std::shared_ptr` or `std::optional` reach the type it holds, looked up through the caller's enclosing classes and their bases, its namespaces, `using` directives and aliases. A held type the project does not declare leaves the call unresolved, as a plain pointer to one does; upstream guesses a callee from the receiver's name there. A partial class template specialization is indexed as an anonymous class, so a call to a member only it declares stays unresolved. - C++ calls on an `auto` local, on a chained call and on a data member of the caller's class (one a base class declares, or a gtest `TEST_F` / `TEST_P` fixture's) take the type its declaration writes, as upstream's do. Where that type cannot be read (a template parameter's, a `std::shared_ptr` of one, a trait of one, what a function template returns), the call stays unlinked, as does a local of a type the file does not declare; upstream guesses a callee from the receiver's name at 0.7. A chain of up to four calls is read from its declarations, where upstream reads its first inner call; a longer one is looked up by the last name, as before. - C++ namespace aliases and declarations in macro-opened namespaces are looked up only in the caller's include closure; upstream pools them across all files. +- C++ object creation sees a header under a test directory, such as nlohmann/json's old-release copy in `tests/abi/`, only from inside that directory's tree or through an `#include`; upstream sees every header. - C# namespace `using` directives and `using` aliases apply only inside their enclosing namespace, not to sibling namespaces in the same file (`using static` is still file-wide, as upstream). - A Java field declared with a qualified type (`outside.Repository`) keeps its qualifier, so calls through it never resolve to an unrelated project class with the same simple name. - Python `super().method()` resolves along the class's C3 method resolution order, starting after the class itself. Upstream resolves it like `self.method()` and drops the link only when it lands on the calling method. From 666cd7441206847385a74a77e65dde23be9e9942 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 05:28:37 -0600 Subject: [PATCH 27/29] fix(cpp): answer review findings on the upstream reconcile A bare call is no longer typed from an inherited or fixture method when a local callable of that name is declared earlier in the function. A type named through a base class is found only in a file the code includes. `__restrict` pointers, `const` pointers to a class template and members followed by an annotation macro without arguments are read for their type. Quoted characters in a template argument no longer cut a type short, and a deleted overload is recognised however many lines its declaration runs over. The files a C++ file sees are computed once per file. --- CHANGELOG.md | 1 + __tests__/cpp-absl-holder-receiver.test.ts | 6 +- .../cpp-class-name-receiver-members.test.ts | 2 +- __tests__/cpp-declarator-edge-cases.test.ts | 129 ++++++++++++++++++ __tests__/cpp-test-fixture-call.test.ts | 25 ++++ codegraph-kernel/src/resolve/cpp_aliases.rs | 13 +- .../src/resolve/cpp_construction.rs | 11 +- .../src/resolve/cpp_implicit_this.rs | 12 +- codegraph-kernel/src/resolve/cpp_overloads.rs | 14 +- codegraph-kernel/src/resolve/cpp_receivers.rs | 2 +- codegraph-kernel/src/resolve/cpp_types.rs | 2 +- codegraph-kernel/src/resolve/mod.rs | 4 + codegraph-kernel/src/resolve/receivers.rs | 4 +- 13 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 __tests__/cpp-declarator-edge-cases.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bcf4ad6b..c0127cf305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -495,6 +495,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a variable or parameter declared over more than one line, with its type begun on a line above or alone on the line above its name, now has its type read, so calls on it link to that class's methods: protocolbuffers/protobuf's `extension.number()` on a `const google::protobuf::internal::ExtensionIdentifier<` … `>& extension` parameter reaches protobuf's `ExtensionIdentifier`, not hpb's, and facebook/rocksdb's `cf_sv_pairs.size()` on an `autovector<…>` written above `cf_sv_pairs;` reaches `autovector`. Calls on a standard-library container declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a variable initialized from a call of a member function template with explicit template arguments now has the type that template returns for them, so calls on it link to that class's methods: protocolbuffers/protobuf's `const auto& rep = reflection->GetRaw>(…)` is a `RepeatedPtrField`, and facebook/rocksdb's `auto bbto = factory->GetOptions()` a `BlockBasedTableOptions`. Re-index C++ projects after upgrading. - In this fork, the two C++ changes above also run in the native resolver. A call on a variable, member or chain whose type it cannot read, such as one a template parameter decides (`T*`, `std::shared_ptr`), an alias of a trait of a parameter, or the result of a function template whose return type is its parameter, stays unlinked rather than being guessed by the receiver's name, and a chain of up to four calls (`cfd->current()->storage_info()->LevelFiles()`) is read from its declarations, not only its first inner call; a longer one is looked up by the last name, as before. A chained call spelled with `.` on a returned `std::unique_ptr` or `std::optional` is that type's own member, not a method of what it holds (`->` still reaches that); and a variable declared in the initializer of a `for`, `if` or `switch` statement is seen before a same-named member the class inherits. +- In C++, a pointer declared `__restrict`, a `const` pointer to a class template and a member followed by an annotation macro that takes no arguments now have their type read, so calls on them link to that class's methods. A quoted `(` or `)` in a template argument no longer cuts the type short, a deleted overload whose declaration runs over many lines is no longer taken, a bare call no longer takes an inherited or fixture method's return type when a local lambda or callable of that name shadows it, and a type named through a base class is found only in a file the code includes. - In this fork, the C++ changes above differ from upstream's in two more ways. A call whose receiver's type can't be read, like a call on an `auto` local whose initializer names a function declared nowhere, or on a name that is neither a member nor a local the method declares, stays unlinked; upstream guesses the class from the receiver's name. And object creation sees a header under a test directory, like nlohmann/json's `tests/abi/include/nlohmann/json_v3_10_5.hpp`, an old release of the library, only from inside that directory's tree or through an `#include`. That copy spells out `namespace nlohmann {`, which the library's own headers open with a macro, so the docs examples' and benchmarks' `json j(…)` and `json::json_pointer p(…)` had linked to its classes, 233 times on nlohmann/json. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. diff --git a/__tests__/cpp-absl-holder-receiver.test.ts b/__tests__/cpp-absl-holder-receiver.test.ts index d656db6922..95a2b6071f 100644 --- a/__tests__/cpp-absl-holder-receiver.test.ts +++ b/__tests__/cpp-absl-holder-receiver.test.ts @@ -120,12 +120,16 @@ describe('C++ absl holders and using-declared members of dependent bases', () => 'int Merge(absl::StatusOr resolver) {', ' return resolver->MergeFeatures(1, 2);', '}', + 'int MergeThroughPointer(absl::StatusOr resolver) {', + ' return resolver->MergeFeatures(1, 2);', + '}', '} // namespace protobuf', '} // namespace google', ), }); try { - expect(calls(cg, 'google::protobuf::Merge').filter((c) => c.startsWith('google::protobuf::FeatureResolver::') && c.endsWith('@0.9'))).toEqual([]); + expect(calls(cg, 'google::protobuf::Merge')).toEqual([]); + expect(calls(cg, 'google::protobuf::MergeThroughPointer')).toEqual([]); } finally { cg.close(); } diff --git a/__tests__/cpp-class-name-receiver-members.test.ts b/__tests__/cpp-class-name-receiver-members.test.ts index b44600631f..c3986f653f 100644 --- a/__tests__/cpp-class-name-receiver-members.test.ts +++ b/__tests__/cpp-class-name-receiver-members.test.ts @@ -80,7 +80,7 @@ describe('C++ calls on a receiver named like a class', () => { ), }); try { - expect(callsAt(cg, 'google::protobuf::Run', 6).filter((t) => /UniquePtr|ThreadSafeArena/.test(t) && t.endsWith('@0.8'))).toEqual([]); + expect(callsAt(cg, 'google::protobuf::Run', 6)).toEqual([]); // `arena` is an `auto` local whose initializer's type can't be read (`MakeArena` is // declared nowhere). The fork's resolver leaves such a call unlinked rather than // guessing the class from the receiver's name, as upstream does at 0.8 (README, diff --git a/__tests__/cpp-declarator-edge-cases.test.ts b/__tests__/cpp-declarator-edge-cases.test.ts new file mode 100644 index 0000000000..fb3259ef57 --- /dev/null +++ b/__tests__/cpp-declarator-edge-cases.test.ts @@ -0,0 +1,129 @@ +/** + * Edge cases of how a C++ declaration's type is read for a call's receiver, and + * which overloads a call reaches: + * + * - `Foo* __restrict p` is a `Foo` pointer, as `Foo* const p` is. + * - An annotation macro without arguments after a declared name + * (`Box* b ABSL_ATTRIBUTE_LIFETIME_BOUND;`) does not hide the declaration. + * - A `const` pointer to a class template (`const Box* b`) is read as a + * `Box`. + * - A deleted overload is none a call reaches, however many lines its + * declaration runs over before the `= delete;`. + */ +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'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-declarator-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What the calls at `line` of the function or method `qualifiedName` reach, as `target:its first line @confidence`. */ +function callsAt(cg: CodeGraph, qualifiedName: string, line: number): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .filter((e) => e.line === line) + .map((e) => { + const t = cg.getNode(e.target)!; + return `${t.qualifiedName}:${t.startLine} @${(e.metadata as { confidence?: number } | undefined)?.confidence}`; + }) + .sort(); +} + +// `Open` is also a namesake class's, so a guess by the name can miss. +const BOXES = lines( + 'template ', + 'class Box {', + ' public:', + ' void Open() {}', + '};', + 'class Cask {', + ' public:', + ' void Open() {}', + '};', +); + +describe('C++ declarations read for a call receiver', () => { + it('reads `Box* __restrict` as a Box pointer', async () => { + const cg = await indexed({ + 'box.h': BOXES, + 'use.cc': lines('#include "box.h"', 'void Use(Cask* __restrict c) {', ' c->Open();', '}'), + }); + try { + expect(callsAt(cg, 'Use', 3)).toEqual(['Cask::Open:8 @0.9']); + } finally { + cg.close(); + } + }); + + it('reads a member followed by an annotation macro that takes no arguments', async () => { + const cg = await indexed({ + 'box.h': BOXES, + 'owner.h': lines( + '#include "box.h"', + '#define LIFETIME_BOUND_ANNOTATION', + 'class Owner {', + ' public:', + ' void Run() { cask_->Open(); }', + ' private:', + ' Cask* const cask_ LIFETIME_BOUND_ANNOTATION;', + '};', + ), + }); + try { + expect(callsAt(cg, 'Owner::Run', 5)).toEqual(['Cask::Open:8 @0.9']); + } finally { + cg.close(); + } + }); + + it('reads a const pointer to a class template as the template', async () => { + const cg = await indexed({ + 'box.h': BOXES, + 'use.cc': lines('#include "box.h"', 'void Use(const Box* b) {', ' b->Open();', '}'), + }); + try { + expect(callsAt(cg, 'Use', 3)).toEqual(['Box::Open:4 @0.9']); + } finally { + cg.close(); + } + }); +}); + +describe('C++ deleted overloads', () => { + it('takes no deleted overload whose declaration runs over many lines', async () => { + const comments = Array.from({ length: 14 }, (_, i) => ` // reason ${i + 1}`); + const cg = await indexed({ + 'meter.h': lines( + 'class Meter {', + ' public:', + ' void Scale(', + ...comments, + ' int factor) = delete;', + ' void Scale(long factor) {}', + ' void Run() { Scale(2); }', + '};', + ), + }); + try { + expect(callsAt(cg, 'Meter::Run', 20)).toEqual(['Meter::Scale:19 @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/cpp-test-fixture-call.test.ts b/__tests__/cpp-test-fixture-call.test.ts index 1406adcc57..da4b6dbb49 100644 --- a/__tests__/cpp-test-fixture-call.test.ts +++ b/__tests__/cpp-test-fixture-call.test.ts @@ -175,6 +175,31 @@ describe('C++ bare calls in gtest TEST_F / TEST_P bodies', () => { } }); + it("does not type `auto it = NewIterator()` from the fixture's method when a local lambda has that name", async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_lambda_test.cc': lines( + '#include "db/db_test_util.h"', + 'namespace rocksdb {', + NAMESAKES, + 'class DBLambdaTest : public DBTestBase {};', + 'TEST_F(DBLambdaTest, LocalLambda) {', + ' auto NewIterator = [&] { return 1; };', + ' auto it = NewIterator();', + ' it->SeekToFirst();', + '}', + '} // namespace rocksdb', + ), + }); + try { + // The call is to the local lambda, so `it` is no `Iterator*` of the fixture's method. + expect(calls(cg, 'rocksdb::TEST_F')).not.toContain('rocksdb::IteratorBase::SeekToFirst @0.9'); + expect(calls(cg, 'rocksdb::TEST_F')).not.toContain('rocksdb::DBTestBase::NewIterator @0.9'); + } finally { + cg.close(); + } + }); + it('does the same in a TEST_P body, and on `this->`', async () => { const cg = await indexed({ ...ROCKSDB, diff --git a/codegraph-kernel/src/resolve/cpp_aliases.rs b/codegraph-kernel/src/resolve/cpp_aliases.rs index 623a254427..240ca649f8 100644 --- a/codegraph-kernel/src/resolve/cpp_aliases.rs +++ b/codegraph-kernel/src/resolve/cpp_aliases.rs @@ -12,7 +12,18 @@ pub(super) fn type_segments(raw: &str) -> Option> { let mut text = String::new(); let mut depth = 0usize; let mut parens = 0usize; + let mut quote = None; + let mut escaped = false; + let mut prev = ' '; for c in raw.chars() { + let before = std::mem::replace(&mut prev, c); + // (A quoted character or string in a template argument holds no grouping: `Bits<('(' == c)>`.) + if let Some(open) = quote { + if escaped { escaped = false; } else if c == '\\' { escaped = true; } else if c == open { quote = None; } + continue; + } + // (A `'` after a digit or letter separates digits: `1'000`.) + if depth > 0 && (c == '"' || (c == '\'' && !before.is_alphanumeric())) { quote = Some(c); continue; } // (In template arguments, a `<` or `>` in parentheses compares: `Bits<(N > 8)>`.) if depth > 0 && matches!(c, '(' | ')') { parens = if c == '(' { parens + 1 } else { parens.saturating_sub(1) }; continue; } if parens > 0 { continue; } @@ -206,7 +217,7 @@ mod tests { use super::type_segments; #[test] fn nested_type_segments() { - for (raw, expected) in [("SkipList", "SkipList"), ("const ::leveldb::SkipList>::Iterator&", "leveldb::SkipList::Iterator"), ("typename Base::Iter", "Base::Iter"), ("struct Node*", "Node")] { + for (raw, expected) in [("SkipList", "SkipList"), ("const ::leveldb::SkipList>::Iterator&", "leveldb::SkipList::Iterator"), ("typename Base::Iter", "Base::Iter"), ("struct Node*", "Node"), ("Bits<('(' == '(')>::Nested", "Bits::Nested"), ("Bits<(\")\" == s)>::Nested", "Bits::Nested"), ("Bits<(1'000 > 8)>::Nested", "Bits::Nested")] { assert_eq!(type_segments(raw).unwrap().join("::"), expected); } assert!(type_segments("unsigned int").is_none()); diff --git a/codegraph-kernel/src/resolve/cpp_construction.rs b/codegraph-kernel/src/resolve/cpp_construction.rs index aee523898f..484516918e 100644 --- a/codegraph-kernel/src/resolve/cpp_construction.rs +++ b/codegraph-kernel/src/resolve/cpp_construction.rs @@ -130,12 +130,15 @@ impl KernelResolver { /// The files whose declarations a C or C++ file can see without knowing its includes: its own /// and every header, but for a header of a test tree, which code outside one sees only by /// including it. - fn cpp_construction_visible_files(&mut self, file: &str) -> Res> { + fn cpp_construction_visible_files(&mut self, file: &str) -> Res>> { + if let Some(hit) = self.cpp_visible_memo.get(file) { return Ok(hit.clone()); } let files = match self.sorted_files() { Some(files) => files, None => Arc::new(self.table()?.files.iter().cloned().collect()) }; let mut visible = HashSet::new(); for f in files.iter().filter(|f| *f == file || !source_file(f)) { if !self.cpp_unincluded_test_header(f, file)? { visible.insert(f.clone()); } } + let visible = Rc::new(visible); + self.cpp_visible_memo.insert(file.to_string(), visible.clone()); Ok(visible) } @@ -261,7 +264,7 @@ impl KernelResolver { /// each class's bases included: protobuf's `using ConformanceRequestSetting = /// BinaryAndJsonConformanceSuite::ConformanceRequestSetting;` names a class the suite inherits /// from `ConformanceTestSuite`. (Port of upstream's #2549.) - pub(super) fn cpp_type_through_bases(&mut self, raw: &str, r: &ResolveRefIn) -> Res>> { + pub(super) fn cpp_type_through_bases(&mut self, raw: &str, r: &ResolveRefIn, included: &HashSet) -> Res>> { let Some(parts) = type_segments(raw) else { return Ok(None) }; let mut scopes: Vec = Vec::new(); if !raw.trim_start().starts_with("::") { @@ -276,12 +279,14 @@ impl KernelResolver { let mut first = None; for count in 1..=parts.len() { let found = self.cpp_scope_types(scope, &parts[..count].join("::"), r, 0)?; + let found = found.into_iter().filter(|n| included.contains(&n.file_path)).collect(); if let Some(found) = pick(found, &r.file_path) { first = Some((found, count)); break; } } let Some((mut current, consumed)) = first else { continue }; for part in &parts[consumed..] { if !class_like(¤t) { return Ok(None); } - let Some(next) = pick(self.cpp_scope_types(¤t.qualified_name, part, r, 0)?, &r.file_path) else { return Ok(None) }; + let next: Vec<_> = self.cpp_scope_types(¤t.qualified_name, part, r, 0)?.into_iter().filter(|n| included.contains(&n.file_path)).collect(); + let Some(next) = pick(next, &r.file_path) else { return Ok(None) }; current = next; } return Ok(Some(current)); diff --git a/codegraph-kernel/src/resolve/cpp_implicit_this.rs b/codegraph-kernel/src/resolve/cpp_implicit_this.rs index f01bdf8d19..4a60d75388 100644 --- a/codegraph-kernel/src/resolve/cpp_implicit_this.rs +++ b/codegraph-kernel/src/resolve/cpp_implicit_this.rs @@ -120,7 +120,7 @@ impl KernelResolver { let Some(caller) = self.node_by_id(&r.from_node_id)? else { return Ok(None) }; let Some(node) = self.cpp_implicit_this_lookup(name, receiver, &caller, r)? else { return Ok(None) }; // (A recursive call names the function itself, which no local shadows.) - if receiver == Receiver::Bare && name != caller.name && self.cpp_local_name(name, &caller, r)? { return Ok(None); } + if receiver == Receiver::Bare && name != caller.name && self.cpp_local_name(name, &caller, r, false)? { return Ok(None); } Ok(Some(KCand { node, confidence: 0.9, resolved_by: "instance-method" })) } @@ -128,6 +128,8 @@ impl KernelResolver { /// `cpp_implicit_this` finds it (cppImplicitThisMethod): what a bare call of a method in a /// member function or a gtest body returns is that method's. pub(super) fn cpp_implicit_this_method(&mut self, name: &str, caller: &Arc, r: &ResolveRefIn) -> Res>> { + // A local of that name (a lambda, a callable) is what a bare call calls. + if name != caller.name && self.cpp_local_name(name, caller, r, true)? { return Ok(None); } self.cpp_implicit_this_lookup(name, Receiver::This, caller, r) } @@ -435,8 +437,9 @@ impl KernelResolver { /// Whether the C++ function `caller` declares `name` before the call at `r` — a parameter, a /// local, a lambda's parameter, a range-`for` variable, a structured binding — which a bare /// call then calls, not a member. A declaration in an earlier block the call is outside of - /// counts too: the name strategies decide, as before. - fn cpp_local_name(&mut self, name: &str, caller: &KNode, r: &ResolveRefIn) -> Res { + /// counts too: the name strategies decide, as before. With `callable`, only a declaration of + /// something a bare call can call (not a pointer). + fn cpp_local_name(&mut self, name: &str, caller: &KNode, r: &ResolveRefIn, callable: bool) -> Res { if caller.file_path != r.file_path || class_kind(&caller.kind) { return Ok(false); } let Some(source) = self.read_file(&r.file_path) else { return Ok(false) }; let lines = source.cpp_code_lines(); @@ -450,6 +453,9 @@ impl KernelResolver { // `std::function done;` reads as a declaration once its template arguments are gone. for variant in [code.clone(), strip_template_arguments(code)] { let Some(ty) = self.cpp_declarator_match(&variant, &escaped)? else { continue }; + // (A pointer is no callable, and a class declared in the function takes its + // constructor's `Env* target`: the name stays a member's.) + if callable && ty.contains('*') { continue; } // `Owner::name(` is a definition's own declarator; `return name(` no declaration. if !ty.trim_end().ends_with("::") && self.normalize_cpp_type_name(&ty, false)?.is_some() { return Ok(true); } } diff --git a/codegraph-kernel/src/resolve/cpp_overloads.rs b/codegraph-kernel/src/resolve/cpp_overloads.rs index b743ce5c4c..f7c131491c 100644 --- a/codegraph-kernel/src/resolve/cpp_overloads.rs +++ b/codegraph-kernel/src/resolve/cpp_overloads.rs @@ -149,11 +149,17 @@ impl KernelResolver { fn cpp_deleted_function(&mut self, n: &KNode, name: &str) -> bool { let Some(source) = self.read_file(&n.file_path) else { return false }; let from = (n.start_line - 1).max(0) as usize; - let text = (from..from + 12).filter_map(|i| source.get(i).map(String::as_str)).collect::>().join("\n"); let Ok(word) = shared_regex(&format!(r"\b{}\s*\(", regex::escape(name))) else { return false }; - let Some(at) = word.find(&text) else { return false }; - let declaration = text[at.start()..].split([';', '{']).next().unwrap_or(""); - re!(r"=\s*delete\b").is_match(declaration) + // (A declaration may run over any number of lines before its `;` or body.) + let mut text = String::new(); + for line in source.iter().skip(from).take(200) { + text.push_str(line); + text.push('\n'); + if let Some(at) = word.find(&text) { + if let Some(end) = text[at.start()..].find([';', '{']) { return re!(r"=\s*delete\b").is_match(&text[at.start()..at.start() + end]); } + } + } + false } /// How well the read argument types `types` fit the parameters of the overload `n`: the same diff --git a/codegraph-kernel/src/resolve/cpp_receivers.rs b/codegraph-kernel/src/resolve/cpp_receivers.rs index 62f698ca2f..81f3f00feb 100644 --- a/codegraph-kernel/src/resolve/cpp_receivers.rs +++ b/codegraph-kernel/src/resolve/cpp_receivers.rs @@ -110,7 +110,7 @@ impl KernelResolver { "->" if pointers == 1 => { let pointee = super::cpp_range_for::cpp_pointee(&raw).unwrap_or_default(); if re!(r"^(?:const\s+)?(?:::)?(?:std|absl)::").is_match(pointee.trim()) { return Ok(Some(None)); } - pointee + super::cpp_range_for::cpp_plain_type(&pointee) } // A standard smart pointer or optional: `->` reaches a member of the type it holds. // So does an `absl::StatusOr`, `absl::optional` or `absl::NoDestructor` of the library's. diff --git a/codegraph-kernel/src/resolve/cpp_types.rs b/codegraph-kernel/src/resolve/cpp_types.rs index d4d88e8cce..a10dccbf5f 100644 --- a/codegraph-kernel/src/resolve/cpp_types.rs +++ b/codegraph-kernel/src/resolve/cpp_types.rs @@ -131,7 +131,7 @@ impl KernelResolver { return self.cpp_expand_owner(owner, r, depth, constructor, included); } // A name C++ finds in a class's base: `Derived::Nested` for a `Nested` the base declares. - if let Some(found) = self.cpp_type_through_bases(raw, r)?.filter(|n| included.contains(&n.file_path)) { + if let Some(found) = self.cpp_type_through_bases(raw, r, included)? { return self.cpp_expand_owner(&found, r, depth, constructor, included); } // Namespace-opening macros can leave a header's indexed owner unqualified. diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 3906994e4b..305f4e2624 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -688,6 +688,9 @@ pub struct KernelResolver { /// A Python class's resolved bases, by class id: they depend on the class's /// own header and file, so every scan over the same classes shares them. py_bases_memo: HashMap>>>, + /// The files a C or C++ file sees without knowing its includes, by file: every + /// construction in the file shares them. + cpp_visible_memo: HashMap>>, /// Assignments and annotations typing a Python class's `self.`, /// by (class id, field). py_field_facts_memo: HashMap<(String, String), Rc>>, @@ -892,6 +895,7 @@ impl KernelResolver { kotlin_supers_memo: HashMap::new(), py_descendants_memo: HashMap::new(), py_bases_memo: HashMap::new(), + cpp_visible_memo: HashMap::new(), py_field_facts_memo: HashMap::new(), py_globals: Default::default(), ruby_ancestry_memo: HashMap::new(), diff --git a/codegraph-kernel/src/resolve/receivers.rs b/codegraph-kernel/src/resolve/receivers.rs index e7b8f7adef..c83aead920 100644 --- a/codegraph-kernel/src/resolve/receivers.rs +++ b/codegraph-kernel/src/resolve/receivers.rs @@ -613,7 +613,7 @@ impl KernelResolver { /// normalizeCppTypeName — strip cv-qualifiers/keywords, refs, generics; /// take the last `::` segment (or the qualified name when preserving). pub(super) fn normalize_cpp_type_name(&mut self, raw: &str, preserve: bool) -> Res> { - let kw = re!(r"(?-u:\b)(?:const|volatile|mutable|typename|class|struct)(?-u:\b)") + let kw = re!(r"(?-u:\b)(?:const|volatile|mutable|typename|class|struct|__restrict__|__restrict)(?-u:\b)") .replace_all(raw, " "); let no_ref = re!(r"[&*]+").replace_all(&kw, " "); let mut no_gen = String::new(); @@ -1226,7 +1226,7 @@ pub(super) const CPP_FUNCTION_WRAPPER_TYPE: &str = r"(?:::\s*)?(?:std\s*::\s*(?:function|move_only_function|copyable_function)|absl\s*::\s*(?:FunctionRef|AnyInvocable))\s*<[^;={}]*>"; /// An annotation macro's call after a declared name: leveldb's `VersionSet* const versions_ /// GUARDED_BY(mutex_);`. -const CPP_TRAILING_ANNOTATION: &str = r"(?-u:\b)[A-Z][A-Z0-9]*_[A-Z0-9_]*\s*\([^()]*\)"; +const CPP_TRAILING_ANNOTATION: &str = r"(?-u:\b)[A-Z][A-Z0-9]*_[A-Z0-9_]*(?:\s*\([^()]*\))?"; /// A declared type as the declarator regex reads it, what it carries included: `const /// FieldDescriptor* PROTOBUF_NULLABLE*`. From 89a79072ac0a09d351c1a7daa246259806616a0d Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 05:42:57 -0600 Subject: [PATCH 28/29] test(cpp): use a valid call in the deleted-overload fixture The fixture now deletes the double overload and calls with an int, which compiles and reaches the int overload, so the call is well-formed C++. --- __tests__/cpp-declarator-edge-cases.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/cpp-declarator-edge-cases.test.ts b/__tests__/cpp-declarator-edge-cases.test.ts index fb3259ef57..8655c39ab3 100644 --- a/__tests__/cpp-declarator-edge-cases.test.ts +++ b/__tests__/cpp-declarator-edge-cases.test.ts @@ -114,8 +114,8 @@ describe('C++ deleted overloads', () => { ' public:', ' void Scale(', ...comments, - ' int factor) = delete;', - ' void Scale(long factor) {}', + ' double factor) = delete;', + ' void Scale(int factor) {}', ' void Run() { Scale(2); }', '};', ), From baaed0d7f7e732d772fb1e56aa32aed7d6d44b31 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 14 Oct 2026 05:46:21 -0600 Subject: [PATCH 29/29] perf(cpp): keep the visible-file set of one C++ file at a time The set holds nearly every header, so one per file grew with files times headers. Constructions of a file are resolved together, so the last file's set serves them. Also reword the changelog line on const class templates. --- CHANGELOG.md | 2 +- codegraph-kernel/src/resolve/cpp_construction.rs | 4 ++-- codegraph-kernel/src/resolve/mod.rs | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0127cf305..c60ff68f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -495,7 +495,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a variable or parameter declared over more than one line, with its type begun on a line above or alone on the line above its name, now has its type read, so calls on it link to that class's methods: protocolbuffers/protobuf's `extension.number()` on a `const google::protobuf::internal::ExtensionIdentifier<` … `>& extension` parameter reaches protobuf's `ExtensionIdentifier`, not hpb's, and facebook/rocksdb's `cf_sv_pairs.size()` on an `autovector<…>` written above `cf_sv_pairs;` reaches `autovector`. Calls on a standard-library container declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a variable initialized from a call of a member function template with explicit template arguments now has the type that template returns for them, so calls on it link to that class's methods: protocolbuffers/protobuf's `const auto& rep = reflection->GetRaw>(…)` is a `RepeatedPtrField`, and facebook/rocksdb's `auto bbto = factory->GetOptions()` a `BlockBasedTableOptions`. Re-index C++ projects after upgrading. - In this fork, the two C++ changes above also run in the native resolver. A call on a variable, member or chain whose type it cannot read, such as one a template parameter decides (`T*`, `std::shared_ptr`), an alias of a trait of a parameter, or the result of a function template whose return type is its parameter, stays unlinked rather than being guessed by the receiver's name, and a chain of up to four calls (`cfd->current()->storage_info()->LevelFiles()`) is read from its declarations, not only its first inner call; a longer one is looked up by the last name, as before. A chained call spelled with `.` on a returned `std::unique_ptr` or `std::optional` is that type's own member, not a method of what it holds (`->` still reaches that); and a variable declared in the initializer of a `for`, `if` or `switch` statement is seen before a same-named member the class inherits. -- In C++, a pointer declared `__restrict`, a `const` pointer to a class template and a member followed by an annotation macro that takes no arguments now have their type read, so calls on them link to that class's methods. A quoted `(` or `)` in a template argument no longer cuts the type short, a deleted overload whose declaration runs over many lines is no longer taken, a bare call no longer takes an inherited or fixture method's return type when a local lambda or callable of that name shadows it, and a type named through a base class is found only in a file the code includes. +- In C++, a pointer declared `__restrict`, a pointer to a `const` class template and a member followed by an annotation macro that takes no arguments now have their type read, so calls on them link to that class's methods. A quoted `(` or `)` in a template argument no longer cuts the type short, a deleted overload whose declaration runs over many lines is no longer taken, a bare call no longer takes an inherited or fixture method's return type when a local lambda or callable of that name shadows it, and a type named through a base class is found only in a file the code includes. - In this fork, the C++ changes above differ from upstream's in two more ways. A call whose receiver's type can't be read, like a call on an `auto` local whose initializer names a function declared nowhere, or on a name that is neither a member nor a local the method declares, stays unlinked; upstream guesses the class from the receiver's name. And object creation sees a header under a test directory, like nlohmann/json's `tests/abi/include/nlohmann/json_v3_10_5.hpp`, an old release of the library, only from inside that directory's tree or through an `#include`. That copy spells out `namespace nlohmann {`, which the library's own headers open with a macro, so the docs examples' and benchmarks' `json j(…)` and `json::json_pointer p(…)` had linked to its classes, 233 times on nlohmann/json. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. diff --git a/codegraph-kernel/src/resolve/cpp_construction.rs b/codegraph-kernel/src/resolve/cpp_construction.rs index 484516918e..9c104daf8a 100644 --- a/codegraph-kernel/src/resolve/cpp_construction.rs +++ b/codegraph-kernel/src/resolve/cpp_construction.rs @@ -131,14 +131,14 @@ impl KernelResolver { /// and every header, but for a header of a test tree, which code outside one sees only by /// including it. fn cpp_construction_visible_files(&mut self, file: &str) -> Res>> { - if let Some(hit) = self.cpp_visible_memo.get(file) { return Ok(hit.clone()); } + if let Some((memo_file, hit)) = &self.cpp_visible_memo { if memo_file == file { return Ok(hit.clone()); } } let files = match self.sorted_files() { Some(files) => files, None => Arc::new(self.table()?.files.iter().cloned().collect()) }; let mut visible = HashSet::new(); for f in files.iter().filter(|f| *f == file || !source_file(f)) { if !self.cpp_unincluded_test_header(f, file)? { visible.insert(f.clone()); } } let visible = Rc::new(visible); - self.cpp_visible_memo.insert(file.to_string(), visible.clone()); + self.cpp_visible_memo = Some((file.to_string(), visible.clone())); Ok(visible) } diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 305f4e2624..a3467a75cc 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -688,9 +688,9 @@ pub struct KernelResolver { /// A Python class's resolved bases, by class id: they depend on the class's /// own header and file, so every scan over the same classes shares them. py_bases_memo: HashMap>>>, - /// The files a C or C++ file sees without knowing its includes, by file: every - /// construction in the file shares them. - cpp_visible_memo: HashMap>>, + /// The files a C or C++ file sees without knowing its includes, for the file last asked: + /// every construction in a file shares them. (One entry: a set holds nearly every header.) + cpp_visible_memo: Option<(String, Rc>)>, /// Assignments and annotations typing a Python class's `self.`, /// by (class id, field). py_field_facts_memo: HashMap<(String, String), Rc>>, @@ -895,7 +895,7 @@ impl KernelResolver { kotlin_supers_memo: HashMap::new(), py_descendants_memo: HashMap::new(), py_bases_memo: HashMap::new(), - cpp_visible_memo: HashMap::new(), + cpp_visible_memo: None, py_field_facts_memo: HashMap::new(), py_globals: Default::default(), ruby_ancestry_memo: HashMap::new(),