Files
codegraph/__tests__/cpp-qualified-declarator-receiver.test.ts
Colby Mchenry fedda29790 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 <noreply@anthropic.com>
2026-10-13 17:40:06 +00:00

439 lines
14 KiB
TypeScript

/**
* 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<string, string>): Promise<CodeGraph> {
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 <memory>',
'#include <vector>',
'#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<Widget> const w) {',
' w->Draw();',
'}',
'void PaintAll(std::vector<Widget*>& ws) {',
' for (Widget* const w : ws) w->Draw();',
'}',
'void PaintEach(std::vector<std::unique_ptr<Widget>>& ws) {',
' for (std::unique_ptr<Widget> const& w : ws) w->Draw();',
'}',
'void PaintMarked(std::vector<Widget*>& 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<void()> 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 <functional>',
'class Job {',
' public:',
' void Finish() {}',
' void Run(std::function<void()> 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();
}
});
});