From e03ff6730aada736d288336aaf96c2bdcc7e0079 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 14 Oct 2026 07:33:54 +0000 Subject: [PATCH] fix(cpp): a receiver declared over more than one line has its type read (#2555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receiver inference read a declaration a line at a time, so a receiver whose type began on a line above (protobuf's `const google::protobuf::internal::ExtensionIdentifier<` over `FeatureSet, TypeTraitsT, field_type, is_packed>& extension`) or sat alone on the line above its name (rocksdb's `autovector<…>` over `cf_sv_pairs;`) declared nothing it could read, and the call fell to name matching. Such a line is now read with the lines it began on: joined until the template brackets pair, or with the type line above, as class members already were. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-nested-template-receiver.test.ts | 7 +- __tests__/cpp-two-line-receivers.test.ts | 164 ++++++++++++++++++ src/resolution/name-matcher.ts | 56 +++++- 4 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 __tests__/cpp-two-line-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b72616c1..9a231a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a declaration continued on a line that starts with `*`, like facebook/rocksdb's `const auto& el =` over `*reinterpret_cast*>(jhandle);`, is now read, so calls such as `el->OnFlushCompleted(…)` link to `EventListener`'s methods; such a line used to be skipped as part of a comment. Code commented out in a `/* … */` block, and the text of a string literal, like protocolbuffers/protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`, are no longer read as declarations, which had sent a call to the wrong method or to none. Re-index C++ projects after upgrading. - In C++, a call with a comparison or a shift among its arguments, like protocolbuffers/protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` or `WriteVarint(num << 3, s)`, is now read with the right number of arguments, so it links to the overload that fits rather than one taking fewer. A parameter or variable whose type has template arguments is now read even after another such parameter on the same line, like facebook/rocksdb's `const autovector& cf_ids`, or with empty ones, like fmt's `parse_context<>& ctx`: calls on it link to that class's methods, and calls on a `std::vector` declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a call passing `nullptr` now links to the overload that takes a `std::nullptr_t`, as C++ picks it, rather than one taking a pointer, and a call no longer links to an overload declared `= delete`, which no call that compiles can reach: protocolbuffers/protobuf's `ptr.reset(nullptr)` on an `Arena::UniquePtr` reaches `reset(std::nullptr_t)`, and `field.Add("foo")` on a `RepeatedPtrField` reaches `Add(Element&&)` instead of the deleted `Add(const Element&)`. Re-index C++ projects after upgrading. +- In C++, a variable or parameter declared over more than one line, with its type begun on a line above or alone on the line above its name, now has its type read, so calls on it link to that class's methods: protocolbuffers/protobuf's `extension.number()` on a `const google::protobuf::internal::ExtensionIdentifier<` … `>& extension` parameter reaches protobuf's `ExtensionIdentifier`, not hpb's, and facebook/rocksdb's `cf_sv_pairs.size()` on an `autovector<…>` written above `cf_sv_pairs;` reaches `autovector`. Calls on a standard-library container declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-nested-template-receiver.test.ts b/__tests__/cpp-nested-template-receiver.test.ts index b6e3e9ac..b911c1c4 100644 --- a/__tests__/cpp-nested-template-receiver.test.ts +++ b/__tests__/cpp-nested-template-receiver.test.ts @@ -195,11 +195,12 @@ describe('a C++ receiver declared with nested template arguments', () => { } }); - it('the end of a declaration begun on the line above names no type, not even its template argument\'s', async () => { + it('the end of a declaration begun on the line above is read with that line, not as its template argument\'s type', async () => { // rocksdb's blob_file_reader.h breaks `autovector>>& blob_reqs` over two lines. The second // line alone, read as a type, is a `CachableEntry` here: the receiver's - // type is the `autovector` on the line above. + // type is the `autovector` on the line above, which the two lines read + // together declare. const cg = await indexed({ ...ROCKSDB, 'table/block_based/cachable_entry.h': [ @@ -228,7 +229,7 @@ describe('a C++ receiver declared with nested template arguments', () => { ].join('\n'), }); try { - expect(calls(cg, 'rocksdb::MultiGetBlob')).toEqual([]); + expect(resolvedCalls(cg, 'rocksdb::MultiGetBlob')).toEqual(['rocksdb::autovector::size instance-method@0.9']); } finally { cg.close(); } diff --git a/__tests__/cpp-two-line-receivers.test.ts b/__tests__/cpp-two-line-receivers.test.ts new file mode 100644 index 00000000..2e302a44 --- /dev/null +++ b/__tests__/cpp-two-line-receivers.test.ts @@ -0,0 +1,164 @@ +/** + * A C++ receiver whose declaration is written over more than one line: its + * type begun on a line above, or alone on the line above its name. Receiver + * inference read a declaration a line at a time, so the line naming the + * receiver declared nothing it could read, and the call fell to name matching: + * protocolbuffers/protobuf's `extension.number()` on a `const + * google::protobuf::internal::ExtensionIdentifier<` / `FeatureSet, + * TypeTraitsT, field_type, is_packed>& extension` reached hpb's + * `ExtensionIdentifier::number`. The lines are now read together. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-two-line-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +const lines = (...text: string[]): string => [...text, ''].join('\n'); + +/** What a function's or method's calls reach, as `qualifiedName resolvedBy@confidence`. */ +function calls(cg: CodeGraph, qualifiedName: string): string[] { + const from = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName)!; + return cg.getOutgoingEdgesFrom([from.id], ['calls']) + .map((e) => { + const meta = e.metadata as { confidence?: number; resolvedBy?: string } | undefined; + return `${cg.getNode(e.target)?.qualifiedName} ${meta?.resolvedBy}@${meta?.confidence}`; + }) + .sort(); +} + +const TABLES = lines( + '#include ', + '#include ', + 'template ', + 'class Table {', + ' public:', + ' int size() const { return 0; }', + '};', + 'class Index {', + ' public:', + ' int size() const { return 0; }', + '};', +); + +describe('C++ receivers declared over more than one line', () => { + it("reads a type begun on a line above: protobuf's ExtensionIdentifier parameter", async () => { + const cg = await indexed({ + 'src/google/protobuf/extension_set.h': lines( + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'namespace internal {', + 'template ', + 'class ExtensionIdentifier {', + ' public:', + ' int number() const { return 0; }', + '};', + '} // namespace internal', + '} // namespace protobuf', + '} // namespace google', + ), + 'hpb/extension.h': lines( + 'namespace hpb {', + 'namespace internal {', + 'template ', + 'class ExtensionIdentifier {', + ' public:', + ' int number() const { return 0; }', + '};', + '} // namespace internal', + '} // namespace hpb', + ), + 'src/google/protobuf/descriptor.h': lines( + '#include "src/google/protobuf/extension_set.h"', + 'namespace google {', + 'namespace protobuf {', + 'class FeatureSet {};', + 'class DescriptorPool {', + ' public:', + ' template ', + ' bool ResolvesFeaturesFor(', + ' const google::protobuf::internal::ExtensionIdentifier<', + ' FeatureSet, TypeTraitsT, field_type, is_packed>& extension) const {', + ' return extension.number() > 0;', + ' }', + '};', + '} // namespace protobuf', + '} // namespace google', + ), + }); + try { + expect(calls(cg, 'google::protobuf::DescriptorPool::ResolvesFeaturesFor')) + .toEqual(['google::protobuf::internal::ExtensionIdentifier::number instance-method@0.9']); + } finally { + cg.close(); + } + }); + + it('reads a type whose template arguments run over several lines above', async () => { + const cg = await indexed({ + 'tables.h': TABLES, + 'visit.cc': lines( + '#include "tables.h"', + 'int Visit(', + ' const Table<', + ' std::string,', + ' std::shared_ptr>& table) {', + ' return table.size();', + '}', + ), + }); + try { + expect(calls(cg, 'Visit')).toEqual(['Table::size instance-method@0.9']); + } finally { + cg.close(); + } + }); + + it('reads a type written alone on the line above the name, and no expression continued there', async () => { + const cg = await indexed({ + 'tables.h': TABLES, + 'build.cc': lines( + '#include "tables.h"', + 'struct Meters {', + ' int value() const { return 0; }', + '};', + 'Meters operator*(int k, const Meters& m);', + 'int Build() {', + ' Table>', + ' table;', + ' Table', + ' *table_ptr = nullptr;', + ' return table.size() + table_ptr->size();', + '}', + 'int Scaled(int factor, const Meters& length) {', + ' Meters total = factor', + ' * length;', + ' return length.value() + total.value();', + '}', + ), + }); + try { + expect(calls(cg, 'Build')).toEqual(['Table::size instance-method@0.9', 'Table::size instance-method@0.9']); + // `* length;` continues `Meters total = factor`: `length` is the parameter. + expect(calls(cg, 'Scaled')).toEqual(['Meters::value instance-method@0.9', 'Meters::value instance-method@0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index df43979e..09724c53 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9092,6 +9092,50 @@ function cppEndsDeclarationBegunAbove(line: string, escapedReceiver: string): bo return end !== null && cppTypeBeganAbove(end[1]!); } +/** + * The type a declaration of the receiver on the code `line` (lines[index]) + * declares when its type is written on lines above, read with them: + * - the end of a type begun above, its text before the name closing a `<` + * an earlier line opened — protobuf's `const + * google::protobuf::internal::ExtensionIdentifier<` above `FeatureSet, + * TypeTraitsT, field_type, is_packed>& extension) const {`. Lines are + * joined, up to six, until the brackets pair; + * - a type alone on the line above, the name starting this one — rocksdb's + * `std::unordered_map>` + * above `props;`, as cppDataMemberIn reads a member. + * Null when the line needs no line above, or the lines read together declare + * no type there. + */ +function cppDeclarationBegunAbove( + lines: readonly string[], + index: number, + line: string, + receiverPattern: RegExp, + declaratorRegex: RegExp, + rawLines: ReturnType, +): string | null { + const at = line.search(receiverPattern); + if (at < 0 || index === 0) return null; + const codeAt = (j: number): string => cppCodeOf(cppLiteralsBlanked(lines[j]!, j + 1, rawLines)) ?? ''; + const head = line.slice(0, at); + if (/^\s*[*&]*\s*$/.test(head)) { + const above = codeAt(index - 1); + return CPP_TYPE_LINE.test(above) ? matchCppDeclarator(`${above} ${line}`, declaratorRegex)?.[1] ?? null : null; + } + // (Only such a line can need the lines above: every other line naming the + // receiver is spared joining them.) + if (!cppTypeBeganAbove(head)) return null; + let text = line; + let before = head; + for (let j = index - 1; j >= Math.max(0, index - 6); j--) { + const above = codeAt(j); + text = `${above} ${text}`; + before = `${above} ${before}`; + if (!cppTypeBeganAbove(before)) return matchCppDeclarator(text, declaratorRegex)?.[1] ?? null; + } + return null; +} + /** * The last name of a declared C++ type: `const std::vector>&` → `vector`, `ns::Table>` → `Table`. Null when the text @@ -11091,8 +11135,16 @@ function inferCppReceiverType( return cppDeclaredType(element.text, elementType, true, ref, context, found); } } - // The end of a declaration begun on an earlier line, maybe the - // receiver's: one found further up may be another variable. + // A declaration begun on an earlier line, read with the lines it began on. + const begun = cppDeclarationBegunAbove(lines, i, line, receiverPattern, declaratorRegex, rawLines); + const begunType = begun && normalizeCppTypeName(begun); + if (begun && begunType && begunType !== 'auto') { + const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, ref, context); + noteCppDeclaration(found, begun, inCallerScope); + return cppDeclaredType(begun, begunType, inCallerScope, ref, context, found); + } + // One those lines don't make readable is still maybe the receiver's: + // one found further up may be another variable. if (cppEndsDeclarationBegunAbove(line, escapedReceiver)) found.shadowed = true; // `for (auto& [cfh, iter] : cfh_iter_pairs_)`, `auto [status, iter] = Open();`. const bound = !found.shadowed ? cppStructuredBindingType(line, lines, i, receiverName, callLineIndex, ref, context, rawLines) : null;