From 518ef066e26a160fb26999712b1d34905132bf39 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 18:53:40 -0600 Subject: [PATCH 1/2] fix(resolve): follow C++ member chains through four more declaration shapes A call on a member reached through another member now links to the member's class when the class or the object is declared in one of the ways google/leveldb writes them: - a nested class defined out of line, `struct DBImpl::CompactionState {`, whose qualified name the field lookup did not match; - a global struct whose member's type comes in through `using leveldb::WriteBatch;`; - a local built with call syntax, `SharedState shared(n);`, which tree-sitter reads as a function declaration; it now counts as an object when every "parameter" is a name a declaration in scope binds as a variable, replacing the smart-pointer-only special case; - a class written with an attribute macro, `class SCOPED_LOCKABLE MutexLock`, `struct LEVELDB_EXPORT Options`; the resolver's re-parse now blanks class-head macros as extraction's pre-parse does. --- CHANGELOG.md | 1 + .../cpp-member-chain-declarations.test.ts | 222 ++++++++++++++++++ .../src/resolve/cpp_annotations.rs | 79 ++++++- codegraph-kernel/src/resolve/cpp_range_for.rs | 5 + codegraph-kernel/src/resolve/cpp_receivers.rs | 49 ++-- 5 files changed, 333 insertions(+), 23 deletions(-) create mode 100644 __tests__/cpp-member-chain-declarations.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae25d944d..99a51252d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -503,6 +503,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 on a member reached through another member, like google/leveldb's `compact->compaction->level()`, now links to the member's class in four more places the class or the object is declared: a nested class defined outside the class it belongs to, like `struct DBImpl::CompactionState { … }`; a global struct whose member's type a `using leveldb::WriteBatch;` brings in, as leveldb's C API does; a local built with call syntax from other variables, like `SharedState shared(n);`, which used to be read as a function declaration; and a class written with an attribute macro, like `class SCOPED_LOCKABLE MutexLock` or `struct LEVELDB_EXPORT Options`. Before, these calls were left unlinked. `Shared shared(Config);`, where `Config` is a type, is still read as a function declaration. 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. diff --git a/__tests__/cpp-member-chain-declarations.test.ts b/__tests__/cpp-member-chain-declarations.test.ts new file mode 100644 index 0000000000..474c26f563 --- /dev/null +++ b/__tests__/cpp-member-chain-declarations.test.ts @@ -0,0 +1,222 @@ +/** + * A C++ call on a member reached through another member (`compact->compaction->level()`, + * `b->rep.Clear()`, `shared.mu.Lock()`, `this->mu_->Lock()`) links to the method of the member's + * class wherever that class or the object's type is declared in one of the ways leveldb writes them: + * + * - a struct defined out of line as a nested class: `struct DBImpl::CompactionState { … };`, + * declared `struct CompactionState;` inside `DBImpl`; + * - a global struct whose member's type comes in through `using leveldb::WriteBatch;`; + * - a local built with constructor-call syntax, `SharedState shared(n);`, which tree-sitter reads + * as a function declaration; it declares an object when its "parameters" are variables in scope; + * - a class written with an attribute macro, `class SCOPED_LOCKABLE MutexLock`, + * `struct LEVELDB_EXPORT Options`, which extraction blanks before parsing. + * + * Each class here has a namesake method on `Decoy`, so a link by the bare method name would pick a + * class the receiver isn't. + */ +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-member-chain-')); + 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; +} + +/** The qualified names of a function's or method's `calls` callees, sorted. */ +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) + .sort(); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +const TYPES = lines( + 'namespace ns {', + 'namespace port {', + 'class Mutex {', + ' public:', + ' void Lock() {}', + ' void Unlock() {}', + '};', + 'class CondVar {', + ' public:', + ' void Wait() {}', + '};', + '} // namespace port', + 'class Compaction {', + ' public:', + ' int level() const { return 0; }', + '};', + 'class Block {', + ' public:', + ' int NewIterator() { return 0; }', + '};', + 'class WriteBatch {', + ' public:', + ' void Clear() {}', + '};', + 'class Env {', + ' public:', + ' virtual void CreateDir() {}', + '};', + 'class Decoy {', + ' public:', + ' void Lock() {}', + ' void Unlock() {}', + ' void Wait() {}', + ' int level() const { return 1; }', + ' int NewIterator() { return 1; }', + ' void Clear() {}', + ' void CreateDir() {}', + '};', + '} // namespace ns', +); + +describe('C++ member chains through how the member is declared', () => { + it('reads a member of a struct defined out of line as a nested class', async () => { + const cg = await indexed({ + 'src/types.h': TYPES, + 'src/table.h': lines( + '#include "src/types.h"', + 'namespace ns {', + 'class Table {', + ' public:', + ' int NewIterator() { return 2; }', + ' void Run();', + ' struct Rep;', + ' private:', + ' Rep* rep_;', + '};', + '} // namespace ns', + ), + 'src/table.cc': lines( + '#include "src/table.h"', + 'namespace ns {', + 'struct Table::Rep {', + ' Block* index_block;', + ' Compaction* const compaction;', + ' port::CondVar cv;', + '};', + 'void Table::Run() {', + ' rep_->index_block->NewIterator();', + ' rep_->compaction->level();', + '}', + 'void T_viaParameter(Table::Rep* r) { r->compaction->level(); r->cv.Wait(); }', + '} // namespace ns', + ), + }); + try { + // The member's class, not the method of the class the struct is nested in. + expect(calls(cg, 'ns::Table::Run')).toEqual(['ns::Block::NewIterator', 'ns::Compaction::level']); + expect(calls(cg, 'ns::T_viaParameter')).toEqual(['ns::Compaction::level', 'ns::port::CondVar::Wait']); + } finally { + cg.close(); + } + }); + + it('reads a member of a global struct whose type a using-declaration brings in', async () => { + const cg = await indexed({ + 'src/types.h': TYPES, + 'src/c.cc': lines( + '#include "src/types.h"', + 'using ns::Env;', + 'using ns::WriteBatch;', + 'struct wb_t { WriteBatch rep; };', + 'struct env_t { Env* rep; };', + 'struct unknown_t { Unknown rep; };', + 'void T_value(wb_t* b) { b->rep.Clear(); }', + 'void T_pointer(env_t* e) { e->rep->CreateDir(); }', + 'void T_unknown(unknown_t* u) { u->rep.Clear(); }', + ), + }); + try { + expect(calls(cg, 'T_value')).toEqual(['ns::WriteBatch::Clear']); + expect(calls(cg, 'T_pointer')).toEqual(['ns::Env::CreateDir']); + // A member whose type names no visible class is called nothing. + expect(calls(cg, 'T_unknown')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('reads a local constructed with call syntax when its arguments are variables', async () => { + const cg = await indexed({ + 'src/types.h': TYPES, + 'src/bench.cc': lines( + '#include "src/types.h"', + 'namespace ns {', + 'struct Config {};', + 'struct Shared {', + ' explicit Shared(int n) {}', + ' port::Mutex mu;', + '};', + 'void T_parameter(int n) { Shared shared(n); shared.mu.Lock(); }', + 'void T_local() { int n = 2; Shared shared(n); shared.mu.Unlock(); }', + // `Config` is a type: this declares a function `shared`, not a `Shared`. + 'void T_functionDeclaration() { Shared shared(Config); shared.mu.Lock(); }', + '} // namespace ns', + ), + }); + try { + expect(calls(cg, 'ns::T_parameter')).toEqual(['ns::port::Mutex::Lock']); + expect(calls(cg, 'ns::T_local')).toEqual(['ns::port::Mutex::Unlock']); + expect(calls(cg, 'ns::T_functionDeclaration')).not.toContain('ns::port::Mutex::Lock'); + } finally { + cg.close(); + } + }); + + it('reads a member of a class written with an attribute macro', async () => { + const cg = await indexed({ + 'src/types.h': TYPES, + 'src/lock.h': lines( + '#include "src/types.h"', + '#define SCOPED_LOCKABLE', + '#define LIB_EXPORT', + 'namespace ns {', + 'class SCOPED_LOCKABLE MutexLock {', + ' public:', + ' explicit MutexLock(port::Mutex* mu) : mu_(mu) { this->mu_->Lock(); }', + ' ~MutexLock() { this->mu_->Unlock(); }', + ' private:', + ' port::Mutex* const mu_;', + '};', + 'struct LIB_EXPORT Options {', + ' Env* env;', + '};', + 'void T_options(const Options& o) { o.env->CreateDir(); }', + '} // namespace ns', + ), + }); + try { + expect(calls(cg, 'ns::MutexLock::MutexLock')).toEqual(['ns::port::Mutex::Lock']); + expect(calls(cg, 'ns::MutexLock::~MutexLock')).toEqual(['ns::port::Mutex::Unlock']); + expect(calls(cg, 'ns::T_options')).toEqual(['ns::Env::CreateDir']); + } finally { + cg.close(); + } + }); +}); diff --git a/codegraph-kernel/src/resolve/cpp_annotations.rs b/codegraph-kernel/src/resolve/cpp_annotations.rs index 66a2b424fa..af46de2bc4 100644 --- a/codegraph-kernel/src/resolve/cpp_annotations.rs +++ b/codegraph-kernel/src/resolve/cpp_annotations.rs @@ -212,11 +212,64 @@ fn declarator_attribute_spans(code: &[u8], spans: &mut Vec<(usize, usize)>) { } } +/// The end of the template arguments opening at `open` (`<`), outside parentheses; None at a `;`, +/// `{` or `}` first. +fn angle_arguments_end(code: &[u8], open: usize) -> Option { + let (mut angles, mut parens) = (0usize, 0usize); + for (i, &b) in code.iter().enumerate().skip(open).take(4000) { + match b { + b'(' => parens += 1, + b')' => parens = parens.checked_sub(1)?, + b';' | b'{' | b'}' => return None, + b'<' if parens == 0 => angles += 1, + b'>' if parens == 0 && code[i - 1] != b'-' => { angles -= 1; if angles == 0 { return Some(i + 1); } } + _ => {} + } + } + None +} + +/// The spans of the macros, with their arguments, between `class` or `struct` and the name of the +/// class it defines: leveldb's `class SCOPED_LOCKABLE MutexLock {` and `struct LEVELDB_EXPORT +/// Options {`, protobuf's `class PROTOBUF_EXPORT Any final : public Message`, rocksdb's `struct +/// ALIGN_AS(64U) HandleImpl : public ClockHandle`. The name may be followed by template arguments +/// and `final`, then the body or the base list. tree-sitter takes the macro for the class's name. +/// +/// Port of c-cpp.ts's preParse pass `blankCppExportMacros`. +fn class_head_macro_spans(code: &[u8], spans: &mut Vec<(usize, usize)>) { + let code_text = String::from_utf8_lossy(code); + let token = re!(r"[A-Z][A-Z0-9_]*(?:\s*\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))?"); + for head in re!(r"\b(?:class|struct)\s+((?:[A-Z][A-Z0-9_]+(?:\s*\((?:[^()]|\([^()]*\))*\))?\s+)+)([A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*)").captures_iter(&code_text) { + let (Some(whole), Some(run), Some(name)) = (head.get(0), head.get(1), head.get(2)) else { continue }; + // Not on a preprocessor line. + let line = code_text[..whole.start()].rfind('\n').map_or(0, |n| n + 1); + if code_text[line..whole.start()].trim_start().starts_with('#') { continue; } + let mut macros: Vec<_> = token.find_iter(run.as_str()).map(|t| (run.start() + t.start(), run.start() + t.end())).collect(); + let mut i = name.end(); + // `class FOO final {`: `final` is no name, so the last macro is the class. + if name.as_str() == "final" { + let Some(last) = macros.pop().filter(|(start, end)| code[*start..*end].iter().all(|b| is_word(*b))) else { continue }; + if macros.is_empty() { continue; } + i = last.1; + } + i = skip_space(code, i); + if code.get(i) == Some(&b'<') { + let Some(end) = angle_arguments_end(code, i) else { continue }; + i = skip_space(code, end); + } + if code[i..].starts_with(b"final") && !code.get(i + 5).is_some_and(|b| is_word(*b)) { i = skip_space(code, i + 5); } + let opens = match code.get(i) { Some(b'{') => true, Some(b':') => code.get(i + 1) != Some(&b':'), _ => false }; + if opens { spans.extend(macros); } + } +} + /// 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 class_head = re!(r"\b(?:class|struct)\s+[A-Z][A-Z0-9_]").is_match(text); + if !class_head && !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(); + if class_head { class_head_macro_spans(&code, &mut spans); } pointer_annotation_spans(&code, text, &mut spans); declarator_attribute_spans(&code, &mut spans); if spans.is_empty() { return Cow::Borrowed(text); } @@ -257,6 +310,30 @@ mod tests { assert!(!out.contains("PROTOBUF_NONNULL") && !out.contains("PROTOBUF_NULLABLE") && !out.contains("GUARDED_BY"), "{out}"); } + #[test] + fn blanks_class_head_macros_before_a_body_or_base_list() { + let blank = |name: &str| " ".repeat(name.len()); + for (text, macros) in [ + ("class SCOPED_LOCKABLE MutexLock {\n", vec!["SCOPED_LOCKABLE"]), + ("struct LEVELDB_EXPORT Options {\n", vec!["LEVELDB_EXPORT"]), + ("class PROTOBUF_EXPORT WARN_UNUSED Any final : public Message {\n", vec!["PROTOBUF_EXPORT", "WARN_UNUSED"]), + ("struct ALIGN_AS(64U) HandleImpl : public ClockHandle {\n", vec!["ALIGN_AS(64U)"]), + ("class API_EXPORT Proxy {\n", vec!["API_EXPORT"]), + ("class API_EXPORT Last final {\n", vec!["API_EXPORT"]), + ] { + let out = cpp_without_annotations(text); + assert_eq!(out.len(), text.len()); + let mut expected = text.to_string(); + for name in macros { expected = expected.replacen(name, &blank(name), 1); } + assert_eq!(out, expected); + } + // A forward declaration, a class named in capitals, a `final` class without a macro, a + // directive and a comment stay as written. + for text in ["class LEVELDB_EXPORT Cache;\n", "struct FOO_BAR {\n", "class FOO final {\n", "#define DECLARE class API_EXPORT Foo {\n", "// class API_EXPORT Foo {\n"] { + assert_eq!(cpp_without_annotations(text), text); + } + } + #[test] fn a_const_qualified_type_precedes_a_declared_name() { let blanked = |text: &str| cpp_without_annotations(text).into_owned(); diff --git a/codegraph-kernel/src/resolve/cpp_range_for.rs b/codegraph-kernel/src/resolve/cpp_range_for.rs index 9bd197737e..b547b03401 100644 --- a/codegraph-kernel/src/resolve/cpp_range_for.rs +++ b/codegraph-kernel/src/resolve/cpp_range_for.rs @@ -328,6 +328,11 @@ impl KernelResolver { if owner.is_none() { let included = self.namespace_visible_files(&r.file_path, "cpp")?; owner = self.cpp_type_owner_visible(primary, &written.site, 0, false, &included)?; + // A name a `using leveldb::WriteBatch;` before where it is written brings in: a global + // struct's `WriteBatch rep;`. + if owner.is_none() { + if let Some(parts) = type_segments(primary) { owner = self.cpp_type_via_using(primary, &parts, &written.site, &included)?; } + } } Ok(owner.filter(|n| cpp_class_node(n))) } diff --git a/codegraph-kernel/src/resolve/cpp_receivers.rs b/codegraph-kernel/src/resolve/cpp_receivers.rs index 0f1c7b4eec..20fb69a8f2 100644 --- a/codegraph-kernel/src/resolve/cpp_receivers.rs +++ b/codegraph-kernel/src/resolve/cpp_receivers.rs @@ -428,7 +428,10 @@ impl KernelResolver { let Some(parent) = node.parent() else { break }; node = parent; } let mut declarations = Vec::new(); - if node.child_by_field_name("name").is_some_and(|name| source.text()[name.start_byte()..name.end_byte()] == owner.name) { + // A nested class defined out of line is named as qualified: `struct DBImpl::CompactionState {`. + let mut name = node.child_by_field_name("name"); + while let Some(qualified) = name.filter(|n| n.kind() == "qualified_identifier") { name = qualified.child_by_field_name("name"); } + if name.is_some_and(|name| source.text()[name.start_byte()..name.end_byte()] == owner.name) { if let Some(body) = node.child_by_field_name("body") { declarations.extend(named_children(body).into_iter().filter(|n| n.kind() == "field_declaration")); } } let mut types = Vec::new(); @@ -589,29 +592,16 @@ pub(super) fn cpp_binding_type(parent: tree_sitter::Node, at: tree_sitter::Node, if block || initializer.is_some() { let children = if block { named_children(parent) } else { initializer.into_iter().collect() }; for declaration in children.into_iter().rev().filter(|n| n.kind() == "declaration" && n.end_byte() <= at.start_byte()) { - // C++ parses a parenthesized initializer as a function declarator. - // Recognize only a standard smart pointer initialized from a known pointer value. + // C++ parses a parenthesized initializer as a function declarator: `SharedState + // shared(n);`, `std::unique_ptr owned(raw);`. Where each of its parameters is a + // bare name a declaration in scope binds as a variable, it is an object constructed + // from them, since C++ reads a name it finds as a variable as no type. if let Some(declarator) = declaration.child_by_field_name("declarator").filter(|n| n.kind() == "function_declarator") { - if declarator.child_by_field_name("declarator").is_some_and(|n| cpp_declarator_name(n, source) == name) { + if let Some(inner) = declarator.child_by_field_name("declarator").filter(|n| cpp_declarator_name(*n, source) == name) { let ty = declaration.child_by_field_name("type")?; - let raw = &source.text()[ty.start_byte()..ty.end_byte()]; - if re!(r"^std::(?:unique_ptr|shared_ptr)\s*<\s*[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\s*>$").is_match(raw) { - let parameters = declarator.child_by_field_name("parameters").map(named_children).unwrap_or_default(); - if let [parameter] = parameters.as_slice() { - if parameter.child_by_field_name("declarator").is_none() { - if let Some(argument) = parameter.child_by_field_name("type").filter(|n| n.kind() == "type_identifier") { - let argument = &source.text()[argument.start_byte()..argument.end_byte()]; - let mut child = declaration; - while let Some(scope) = child.parent() { - if let Some(bound) = cpp_binding_type(scope, child, argument, source) { - if bound.ends_with('*') && !bound.contains('[') { return Some(raw.to_string()); } - break; - } - child = scope; - } - } - } - } + let parameters = declarator.child_by_field_name("parameters").map(named_children).unwrap_or_default(); + if !parameters.is_empty() && parameters.iter().all(|parameter| cpp_names_variable(*parameter, declaration, source)) { + return Some(cpp_ast_declared_type(ty, inner, source)); } // This declaration shadows the receiver even when its type is unproven. return Some(String::new()); @@ -637,6 +627,21 @@ pub(super) fn cpp_binding_type(parent: tree_sitter::Node, at: tree_sitter::Node, None } +/// Whether `parameter`, of a declaration tree-sitter reads as a function's, is a bare name that a +/// declaration in scope before `declaration` binds as a variable of a known type: `n` in +/// `SharedState shared(n);` after `int n`. Not a name it can't place, which may be a type. +fn cpp_names_variable(parameter: tree_sitter::Node, declaration: tree_sitter::Node, source: &SourceFile) -> bool { + if parameter.kind() != "parameter_declaration" || parameter.child_by_field_name("declarator").is_some() { return false; } + let Some(argument) = parameter.child_by_field_name("type").filter(|n| n.kind() == "type_identifier") else { return false }; + let argument = &source.text()[argument.start_byte()..argument.end_byte()]; + let mut child = declaration; + while let Some(scope) = child.parent() { + if let Some(bound) = cpp_binding_type(scope, child, argument, source) { return !bound.is_empty(); } + child = scope; + } + false +} + /// Whether the declaration of the name `node` in scope writes `const` (or `constexpr`) in front of /// its type: `const Buffer* b`, `constexpr char name[] = "name"`, which the declared type /// (`cpp_ast_declared_type`) leaves out. A `constexpr` pointer (`constexpr Widget* p`) is itself From 548f13d59add870ca103c2580fbdf17ba9304985 Mon Sep 17 00:00:00 2001 From: bompus Date: Fri, 16 Oct 2026 19:01:32 -0600 Subject: [PATCH 2/2] test(resolve): a C++ call through a raw pointer to std::string links no project method --- ...pp-member-chain-outside-calls-fork.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 __tests__/cpp-member-chain-outside-calls-fork.test.ts diff --git a/__tests__/cpp-member-chain-outside-calls-fork.test.ts b/__tests__/cpp-member-chain-outside-calls-fork.test.ts new file mode 100644 index 0000000000..83efd41043 --- /dev/null +++ b/__tests__/cpp-member-chain-outside-calls-fork.test.ts @@ -0,0 +1,58 @@ +/** + * Fork-side case beside `cpp-member-chain-outside-calls.test.ts`: a C++ `->` call through a raw + * pointer to a library type, `out->clear()` on a `std::string* out`, calls the library type's own + * method, not a project method that only shares its name. + */ +import { describe, it, expect, beforeAll, afterAll } 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 lines = (...l: string[]): string => [...l, ''].join('\n'); + +describe('a C++ call through a raw pointer to a type from outside the project', () => { + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-raw-pointer-')); + const write = (file: string, text: string): void => { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + }; + write('src/buffer.h', lines( + 'class Buffer {', + ' public:', + ' void clear() {}', + '};', + )); + write('src/use.cc', lines( + '#include ', + '#include "src/buffer.h"', + 'void RawPointer(std::string* out) { out->clear(); }', + 'void ProjectPointer(Buffer* b) { b->clear(); }', + )); + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + const callees = (name: string): string[] => { + const fn = cg.getNodesByKind('function').find((n: Node) => n.name === name); + if (!fn) throw new Error(`no function ${name}`); + return cg.getCallees(fn.id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.qualifiedName); + }; + + it('does not link `out->clear()` on a `std::string*` to a project `clear`', () => { + expect(callees('RawPointer')).not.toContain('Buffer::clear'); + }); + + it('still links `b->clear()` on a pointer to the project class', () => { + expect(callees('ProjectPointer')).toEqual(['Buffer::clear']); + }); +});