diff --git a/CHANGELOG.md b/CHANGELOG.md index bb37f95b..5bad5721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -257,6 +257,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript, an interface that extends other interfaces, like `interface UserRepo extends Repo, Named`, is now linked to each of them: the type hierarchy shows them above the interface and above every class that implements it, a change to an interface reaches the interfaces built on it and their implementations in impact results, and a call on a value typed by such an interface, like `hass.formatEntityName(…)` on a `HomeAssistant` that extends seven interfaces, links to the method the interface inherits instead of a same-named method elsewhere. Before, only a class's `extends` and `implements` were read, so every interface stood alone. A type from a package, like `ButtonProps` from `@mui/material`, or one of TypeScript's own, like `Omit<…>`, still links to nothing rather than to a type of the same name elsewhere in your project. Re-index TypeScript projects after upgrading. - In TypeScript and JavaScript, a type, class, constant or other declaration that a `.d.ts` file declares inside `declare module '…'` now links only from files that use that module: files that import it, the module's other declarations, and files that import it through a file of yours that re-exports it. Before, any file using the same name linked to it: Node's `import { readFileSync } from 'fs'` linked to the `fs` that VS Code's `vscode.d.ts` declares, and a `ProcessEnv`, or an LSP `TextDocument` imported through a barrel, to the `vscode` module's types of those names. Re-index TypeScript projects after upgrading. - In C++, a call written through a class the index doesn't hold, like protocolbuffers/protobuf's `FeatureSet::descriptor()` or `OneofOptions::default_instance()` (both classes are declared in `descriptor.pb.h`, a file too large to index), no longer links to another class's method of that name whose class only shares a word with it, like `JavaFeatures_NestInFileClassFeature::descriptor` or `CppFileOptions::default_instance`. A class named like the call's would have been found first, so one that only shares a word is another class. A call through a template parameter, through an alias, including one the calling function or a macro it expands declares, or through a namespace the file opens, aliases or uses links as before, and so does a call of a method only one class in the project declares. Re-index C++ projects after upgrading. +- In C++, a call written through a namespace, like protocolbuffers/protobuf's `absl::StripPrefix(…)` or fmtlib/fmt's `fmt::ptr(p)`, no longer links to a class's method that only shares the function's name, like protobuf's `MultiCasePrefixStripper::StripPrefix` or a test scanner's `ptr()`: a namespace holds functions and types, not methods. A call through a namespace a macro opens now links to what that namespace declares, so fmt's `fmt::ptr(p)` reaches fmt's own `ptr` function and `fmt::day(…)` its `day` type, while a call into a library outside your project, like abseil, links to nothing. A call through a lowercase class, or an alias or typedef of one, links as before. Re-index C++ projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/cpp-namespace-qualifier.test.ts b/__tests__/cpp-namespace-qualifier.test.ts new file mode 100644 index 00000000..058b72c7 --- /dev/null +++ b/__tests__/cpp-namespace-qualifier.test.ts @@ -0,0 +1,373 @@ +/** + * A C++ call through a namespace — protobuf's `absl::StripPrefix(…)`, fmt's + * `fmt::ptr(p)` — links no class's method, which is all the name strategies + * of matchMethodCall can find: a namespace holds functions and types. + * + * protocolbuffers/protobuf's `absl::StripPrefix(name, …)` went to its Rust + * generator's `MultiCasePrefixStripper::StripPrefix`, the one method of that + * name, and `absl::Status(…)` to `upb::Status`'s constructor. fmtlib/fmt's + * `fmt::ptr(p)` went to a scanner's `ptr()` method; fmt's own `ptr` function, + * which `FMT_BEGIN_NAMESPACE` puts in `fmt`, is found by the step after those + * strategies, and is now what the call links. + * + * A lowercase qualifier is a namespace when no C or C++ type or alias has its + * name and the calling file declares it as neither a template parameter nor + * an alias. rocksdb's `hash_table::Insert(…)`, through `typedef HashTable<…> + * hash_table;`, still reaches `HashTable::Insert`. + */ +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-namespace-')); + 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 `kind qualifiedName (file)`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.kind} ${r.node.qualifiedName} (${r.node.filePath})`) + .sort(); +} + +const lines = (...l: string[]): string => [...l, ''].join('\n'); + +/** fmt's namespace macros, and the scanner whose `ptr()` main took for `fmt::ptr`. */ +const FMT = { + 'include/fmt/base.h': lines( + '#define FMT_BEGIN_NAMESPACE namespace fmt { inline namespace v11 {', + '#define FMT_END_NAMESPACE } }', + ), + 'include/fmt/format.h': lines( + '#include "fmt/base.h"', + 'FMT_BEGIN_NAMESPACE', + 'template const void* ptr(T p) { return p; }', + 'FMT_END_NAMESPACE', + ), + 'include/fmt/chrono.h': lines( + '#include ', + '#include "fmt/base.h"', + 'FMT_BEGIN_NAMESPACE', + 'using day = std::chrono::day;', + 'FMT_END_NAMESPACE', + ), + 'test/scan.h': lines( + '#include "fmt/base.h"', + 'FMT_BEGIN_NAMESPACE', + 'namespace detail {', + 'class scan_buffer {', + ' public:', + ' const char* ptr() const { return ptr_; }', + ' private:', + ' const char* ptr_;', + '};', + '} // namespace detail', + 'FMT_END_NAMESPACE', + ), + 'test/util.h': lines( + 'namespace date {', + 'class day {', + ' public:', + ' explicit day(unsigned d) : d_(d) {}', + ' private:', + ' unsigned d_;', + '};', + '} // namespace date', + ), +}; + +describe('a C++ call through a namespace', () => { + it('reproduction: `fmt::ptr(p)` is fmt\'s own `ptr`, not a scanner\'s `ptr()` method', async () => { + const cg = await indexed({ + ...FMT, + 'test/format-test.cc': lines( + '#include "fmt/format.h"', + '#include "scan.h"', + 'const void* Pointer(int* p) { return fmt::ptr(p); }', + ), + }); + try { + expect(calls(cg, 'Pointer')).toEqual(['function ptr (include/fmt/format.h)']); + } finally { + cg.close(); + } + }); + + it('a type a namespace macro puts in fmt: `fmt::day(1)` is fmt\'s alias, not a test\'s `date::day`', async () => { + const cg = await indexed({ + ...FMT, + 'test/chrono-test.cc': lines( + '#include "fmt/chrono.h"', + '#include "util.h"', + 'void Days() { auto d = fmt::day(1); }', + ), + }); + try { + expect(calls(cg, 'Days')).toEqual(['type_alias day (include/fmt/chrono.h)']); + } finally { + cg.close(); + } + }); + + it('an outside library\'s namespace: `absl::StripPrefix` and `absl::Status` link nothing', async () => { + const cg = await indexed({ + 'src/google/protobuf/compiler/rust/naming.h': lines( + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'namespace compiler {', + 'namespace rust {', + 'class MultiCasePrefixStripper {', + ' public:', + ' std::string StripPrefix(std::string name) const { return name; }', + '};', + '} // namespace rust', + '} // namespace compiler', + '} // namespace protobuf', + '} // namespace google', + ), + 'hpb/status.h': lines( + 'namespace upb {', + 'class Status {', + ' public:', + ' Status() : ok_(true) {}', + ' private:', + ' bool ok_;', + '};', + '} // namespace upb', + ), + 'src/google/protobuf/compiler/python/generator.cc': lines( + '#include ', + '#include "absl/status/status.h"', + '#include "absl/strings/strip.h"', + 'namespace google {', + 'namespace protobuf {', + 'namespace compiler {', + 'namespace python {', + 'std::string ModuleName(std::string name) {', + ' auto status = absl::Status();', + ' return absl::StripPrefix(name, "google.protobuf.");', + '}', + '} // namespace python', + '} // namespace compiler', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::compiler::python::ModuleName')).toEqual([]); + } finally { + cg.close(); + } + }); +}); + +describe('every name strategy steps aside for a namespace', () => { + it('the class named like the namespace, the best word guess, and a namespace the calling file opens', async () => { + const cg = await indexed({ + 'src/internal.h': lines( + 'namespace leveldb {', + 'class Internal {', + ' public:', + ' static void Fill(int n) {}', + '};', + 'class LogWriter {', + ' public:', + ' void Flush() {}', + '};', + 'class TableBuilder {', + ' public:', + ' void Flush() {}', + '};', + 'class PyGenerator {', + ' public:', + ' static int Escape(int n) { return n; }', + '};', + '} // namespace leveldb', + ), + 'src/use.cc': lines( + '#include "src/internal.h"', + 'namespace leveldb {', + 'namespace python {', + 'void Use(int n) {', + ' internal::Fill(n);', + ' log::Flush();', + ' python::Escape(n);', + '}', + '} // namespace python', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::python::Use')).toEqual([]); + } finally { + cg.close(); + } + }); +}); + +describe('the constructor of a class a call names in its namespace', () => { + it('is kept: protobuf\'s `internal::SourceWrapper(&data)` constructs the specialization', async () => { + const cg = await indexed({ + 'src/google/protobuf/message_lite.h': lines( + 'namespace google {', + 'namespace protobuf {', + 'namespace internal {', + 'template ', + 'struct SourceWrapper;', + '} // namespace internal', + '} // namespace protobuf', + '} // namespace google', + ), + 'src/google/protobuf/message_lite.cc': lines( + '#include "google/protobuf/message_lite.h"', + '#include "absl/strings/cord.h"', + 'namespace google {', + 'namespace protobuf {', + 'namespace internal {', + 'template <>', + 'struct SourceWrapper {', + ' explicit SourceWrapper(const absl::Cord* c) : cord(c) {}', + ' const absl::Cord* cord;', + '};', + '} // namespace internal', + 'int ParseCord(const absl::Cord& data) {', + ' auto source = internal::SourceWrapper(&data);', + ' return source.cord == nullptr;', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + const constructors = cg.getNodesByKind('method').filter((n) => n.name === 'SourceWrapper'); + expect(constructors).toHaveLength(1); + expect(calls(cg, 'google::protobuf::ParseCord')).toEqual([`method ${constructors[0]!.qualifiedName} (src/google/protobuf/message_lite.cc)`]); + } finally { + cg.close(); + } + }); + + it('but not a method of another template\'s specialization: `fmt::scan(…)` is no `scanner::scan`', async () => { + const cg = await indexed({ + 'include/fmt/base.h': FMT['include/fmt/base.h'], + 'test/scan.h': lines( + '#include "fmt/base.h"', + 'FMT_BEGIN_NAMESPACE', + 'template struct scanner;', + 'template int scan(const char* input) { return 0; }', + 'FMT_END_NAMESPACE', + ), + 'test/scan-test.cc': lines( + '#include "scan.h"', + 'struct num {', + ' int value;', + '};', + 'namespace fmt {', + 'template <> struct scanner {', + ' int scan(num& n) const { return n.value; }', + '};', + '} // namespace fmt', + 'int Scan() { return fmt::scan("42"); }', + ), + }); + try { + expect(calls(cg, 'Scan')).toEqual(['function scan (test/scan.h)']); + } finally { + cg.close(); + } + }); +}); + +describe('a lowercase qualifier that is a type is untouched', () => { + it('a typedef the class declares: rocksdb\'s `hash_table::Insert` is HashTable::Insert', async () => { + const cg = await indexed({ + 'utilities/persistent_cache/hash_table_evictable.h': lines( + 'namespace rocksdb {', + 'template ', + 'class HashTable {', + ' public:', + ' bool Insert(const T& t) { return true; }', + '};', + 'template ', + 'class EvictableHashTable : private HashTable {', + ' public:', + ' typedef HashTable hash_table;', + ' bool Insert(T* t) { return hash_table::Insert(t); }', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::EvictableHashTable::Insert')).toEqual([ + 'method rocksdb::HashTable::Insert (utilities/persistent_cache/hash_table_evictable.h)', + ]); + } finally { + cg.close(); + } + }); + + it('an alias the calling function declares, a template parameter\'s default, and a class', async () => { + const cg = await indexed({ + 'util/ring.h': lines( + 'namespace rocksdb {', + 'class RingBuffer {', + ' public:', + ' static int Capacity() { return 8; }', + '};', + 'struct default_policy {', + ' static int apply() { return 1; }', + '};', + 'class string_buffer {', + ' public:', + ' static int initial_size() { return 500; }', + '};', + 'using ring_buffer = RingBuffer;', + '} // namespace rocksdb', + ), + 'util/ring.cc': lines( + '#include "util/ring.h"', + 'namespace rocksdb {', + 'int Capacity() {', + ' using ring = RingBuffer;', + ' return ring::Capacity();', + '}', + 'int HeaderCapacity() { return ring_buffer::Capacity(); }', + 'template ', + 'int Apply() { return policy::apply(); }', + 'int InitialSize() { return string_buffer::initial_size(); }', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Capacity')).toEqual(['method rocksdb::RingBuffer::Capacity (util/ring.h)']); + expect(calls(cg, 'rocksdb::HeaderCapacity')).toEqual(['method rocksdb::RingBuffer::Capacity (util/ring.h)']); + expect(calls(cg, 'rocksdb::Apply')).toEqual(['method rocksdb::default_policy::apply (util/ring.h)']); + expect(calls(cg, 'rocksdb::InitialSize')).toEqual(['method rocksdb::string_buffer::initial_size (util/ring.h)']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index be2ccc8a..667a1261 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -16163,13 +16163,55 @@ function isUndeclaredTypeName(typeName: string, ref: UnresolvedRef, context: Res * (cppNamesBroughtIn). */ function isCppUndeclaredQualifier(qualifier: string, ref: UnresolvedRef, context: ResolutionContext): boolean { - return isUndeclaredTypeName(qualifier, ref, context) && - cppTypesNamed(qualifier, context).length === 0 && - !context.getNodesByName(qualifier).some((n) => (n.language === 'cpp' || n.language === 'c') && (n.kind === 'namespace' || n.kind === 'module')) && - !cppNamesBroughtIn(ref.filePath, context).has(qualifier); + if (!isUndeclaredTypeName(qualifier, ref, context) || cppTypesNamed(qualifier, context).length > 0 || + context.getNodesByName(qualifier).some((n) => (n.language === 'cpp' || n.language === 'c') && (n.kind === 'namespace' || n.kind === 'module'))) return false; + const names = cppNamesBroughtIn(ref.filePath, context); + return !names.types.has(qualifier) && !names.namespaces.has(qualifier); } -const CPP_FILE_TYPE_NAMES = new WeakMap>>(); +/** + * Is the qualifier of a C++ `x::m()` call a namespace, `absl` in + * `absl::StripPrefix(…)` or `fmt` in `fmt::ptr(p)`, and not a type? A + * lowercase name that no C or C++ type or alias node has, and that the + * calling file doesn't declare as a template parameter or an alias + * (cppNamesBroughtIn). A namespace holds functions and types, never a + * class's method. + */ +function isCppNamespaceQualifier(qualifier: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + return /^[a-z_]/.test(qualifier) && cppTypesNamed(qualifier, context).length === 0 && + !cppNamesBroughtIn(ref.filePath, context).types.has(qualifier); +} + +/** + * Is `method` the constructor of the class a C++ call `ns::m(…)` names + * through its namespace? Its owner, right inside `ns`, is `m` itself or a + * specialization of the class template `m`, which the index holds without + * a name: protobuf's `internal::SourceWrapper(&data)` constructs + * the `struct SourceWrapper` in message_lite.cc, whose template + * is only declared. fmt's `template <> struct scanner` is no `scan`. + */ +function isCppConstructorThrough(namespace: string, method: Node, context: ResolutionContext): boolean { + const parts = method.qualifiedName.split('::'); + if (parts.length < 3 || parts[parts.length - 3] !== namespace) return false; + const owner = parts[parts.length - 2]!; + if (owner === method.name) return true; + if (owner !== '') return false; + const holder = context.getNodesByQualifiedName(parts.slice(0, -1).join('::')).find((n) => + n.filePath === method.filePath && n.startLine <= method.startLine && (n.endLine ?? n.startLine) >= method.startLine); + const lines = holder ? context.getFileLines?.(holder.filePath) ?? context.readFile(holder.filePath)?.split(/\r?\n/) : undefined; + const head = holder && lines ? lines.slice(holder.startLine - 1, holder.startLine + 1).join(' ') : ''; + return new RegExp(String.raw`\b(?:class|struct|union)\s+(?:[A-Za-z_]\w*\s+)*${method.name}\s*<`).test(head); +} + +/** The names a C++ file makes something of itself, which the index holds no node for (see cppNamesBroughtIn). */ +interface CppFileNames { + /** Template parameters and aliases. */ + types: ReadonlySet; + /** The namespaces it opens, aliases or uses. */ + namespaces: ReadonlySet; +} + +const CPP_FILE_TYPE_NAMES = new WeakMap>(); const CPP_MACRO_TYPE_NAMES = new WeakMap>>(); /** @@ -16180,20 +16222,22 @@ const CPP_MACRO_TYPE_NAMES = new WeakMap { +function cppNamesBroughtIn(file: string, context: ResolutionContext): CppFileNames { let memo = CPP_FILE_TYPE_NAMES.get(context); if (!memo) CPP_FILE_TYPE_NAMES.set(context, (memo = new Map())); const hit = memo.get(file); if (hit) return hit; const text = stripCommentsForRegex((context.readFile(file) ?? '').replace(/\\\r?\n/g, ' '), 'cpp'); - const names = cppTypeNamesDeclared(text); + const types = cppTypeNamesDeclared(text); + const namespaces = new Set(); for (const m of text.matchAll(/\bnamespace\s+([^;{}()]{1,200}?)\s*[;{]/g)) { - for (const name of m[1]!.match(/[A-Za-z_]\w*/g) ?? []) names.add(name); + for (const name of m[1]!.match(/[A-Za-z_]\w*/g) ?? []) namespaces.add(name); } for (const [macro, declared] of cppMacroTypeNames(context)) { - if (hasCppWord(text, macro)) for (const name of declared) names.add(name); + if (hasCppWord(text, macro)) for (const name of declared) types.add(name); } - if (/\bTYPED_TEST(?:_P)?\s*\(/.test(text)) names.add('TypeParam').add('TestFixture'); + if (/\bTYPED_TEST(?:_P)?\s*\(/.test(text)) types.add('TypeParam').add('TestFixture'); + const names = { types, namespaces }; memo.set(file, names); return names; } @@ -19165,6 +19209,17 @@ export function matchMethodCall( return null; } + // A C++ name through a namespace, `absl::StripPrefix(…)` or `fmt::ptr(p)`, + // is a function or type of that namespace, never a class's method, which + // is all the strategies below find: protobuf's `absl::StripPrefix` went to + // `rust::MultiCasePrefixStripper::StripPrefix`, and fmt's `fmt::ptr` to a + // scanner's `ptr` method, past fmt's own `ptr`, which a namespace macro + // declares and is read after them. The one exception is the constructor of + // a class the call names in that namespace (isCppConstructorThrough). + // Asked only of a guess about to be made. + const throughCppNamespace = (): boolean => + ref.language === 'cpp' && match === colonMatch && isCppNamespaceQualifier(objectOrClass!, ref, context); + // Strategy 2: Instance variable receiver - try capitalized form to find class // e.g., "permissionEngine" → look for a class named "PermissionEngine" const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1); @@ -19186,6 +19241,7 @@ export function matchMethodCall( const methodNode = classStrategyMethod(classNode, methodName!, context); if (methodNode) { + if (throughCppNamespace()) return null; return { original: ref, targetNodeId: methodNode.id, @@ -19299,6 +19355,7 @@ export function matchMethodCall( !sharesReceiverWord(objectOrClass!, targetMethods[0]!) && !(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)) && !(ref.language === 'php' && phpReceiverReaches(objectOrClass!, targetMethods[0]!, context)))) { + if (throughCppNamespace() && !isCppConstructorThrough(objectOrClass!, targetMethods[0]!, context)) return null; return { original: ref, targetNodeId: targetMethods[0]!.id, @@ -19382,6 +19439,7 @@ export function matchMethodCall( // `JavaFeatures_NestInFileClassFeature::descriptor`, its Python // extension's `OneofOptions::default_instance()` to `CppFileOptions`'. if (ref.language === 'cpp' && match === colonMatch && isCppUndeclaredQualifier(objectOrClass!, ref, context)) return null; + if (throughCppNamespace() && !isCppConstructorThrough(objectOrClass!, bestMatch, context)) return null; return { original: ref, targetNodeId: bestMatch.id,