diff --git a/CHANGELOG.md b/CHANGELOG.md index 7060940d..2f23fe81 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 00000000..714d7189 --- /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 abd407c7..49e893e3 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);