From ee99d358f6f130df03f251323ee82daa9299bf14 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 05:29:33 -0500 Subject: [PATCH 1/2] fix(cpp): a range-based for loop's variable has the type the loop declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C++ receiver inference read a declaration only when the declared name was followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so a range-based for's `Type name :` was never read. In protocolbuffers/protobuf's conformance runner, `suite->SetVerbose(…)`, `suite->RunSuite(…)` and `suite->GetFailureListFlagName()` inside `for (ConformanceTestSuite *suite : suites)` fell through to Strategy 3's guess by the receiver's name, and in RocksDB `log->file()` on a `log::Writer` loop variable went to `BlobLogWriter::file`. A line the declarator regex doesn't match is now read as a range-based for when its `for (` header declares the receiver before a single `:` (not `::`), and the loop's variable is taken only when the call is inside the loop's body: past the header, up to the `}` closing a braced body or the end of a single-statement body, with comments and literals skipped. After the loop, or in a later function, the same name is another variable, and the scan goes on as before. A loop over `auto` elements has nothing to read and keeps the existing fallback. Bit-fields and the scope operator are never read as a loop's declaration. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-range-for-receiver.test.ts | 419 +++++++++++++++++++++++ src/resolution/name-matcher.ts | 111 +++++- 3 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 __tests__/cpp-range-for-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa4c1d08..bf5e7814d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. - In C++, a call on a variable, parameter or member declared through a `typedef` or `using` alias now links to the method of the type the alias names, with the alias looked up the way C++ does it: in the calling function, its class and the classes that class inherits from, then the namespaces around it. In google/leveldb, `table_.Insert(buf)` on a `Table table_;` declared next to `typedef SkipList Table;` now reaches `SkipList::Insert` instead of the unrelated `HandleTable::Insert`, and `Table::Iterator iter(&table_);` reaches `SkipList::Iterator`'s constructor. A class that only shares the alias's name is no longer taken for it, and when the aliased type has no such method, as with an alias of `std::vector` or of a template parameter, no method is guessed from the receiver's name. Re-index C++ projects after upgrading. - In C++, a base class like `Message` in `class DynamicMessage final : public Message` now links to the class C++ itself would pick: the one declared in the namespaces around the class, or brought in by the file's `using` declarations and namespace aliases, and the class a `typedef` or `using` alias names. Before, a base class was matched by its name alone, so it could land on a class of the same name in another namespace or another language: in protocolbuffers/protobuf, `DynamicMessage` derived from the JSON parser's internal `Message` class, and in google/leveldb and RocksDB, iterators derived from an unrelated nested `Iterator` class such as the skip list's. The wrong base showed up in `codegraph_explore`'s type hierarchy and in impact, and tied the base's methods to the wrong overrides. A template parameter used as a base no longer links to a class that shares its name, and a base written from the global scope, like `public ::testing::Test`, now links to its class. Re-index C++ projects after upgrading. +- In C++, a call on a range-based `for` loop's variable, like `suite->SetVerbose(verbose)` inside `for (ConformanceTestSuite *suite : suites)`, now links to the method of the type the loop declares. Before, the loop's declaration was never read, so the call was matched by the variable's name alone: in RocksDB, `log->file()` on a `log::Writer` went to `BlobLogWriter::file`, and a loop variable named like a member of its class was taken for that member. The variable counts only inside its loop, and loops over `auto` elements resolve as before. 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 Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. diff --git a/__tests__/cpp-range-for-receiver.test.ts b/__tests__/cpp-range-for-receiver.test.ts new file mode 100644 index 000000000..1eec4539f --- /dev/null +++ b/__tests__/cpp-range-for-receiver.test.ts @@ -0,0 +1,419 @@ +/** + * A C++ range-based for loop declares the receiver its body calls through. + * + * protocolbuffers/protobuf's conformance runner loops `for + * (ConformanceTestSuite *suite : suites)` and calls `suite->SetVerbose(…)`, + * `suite->RunSuite(…)` and `suite->GetFailureListFlagName()` in the body. + * Receiver inference reads a declaration only when the declared name is + * followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so it never read a loop's + * `Type name :` and the calls fell through to a guess by the receiver's name. + * A guess can still land on the right method by a shared word, so these tests + * check how the edge was made (the typed path's confidence), not only where + * it goes. + * + * The loop's variable is in scope in the loop's body only: after the loop, or + * in another function, a receiver of the same name is some other variable. + * A `for` in a comment or a string literal declares nothing, and a colon + * outside a loop's header never ends a declaration: not a bit-field's + * (`unsigned car : 1;`), nor the scope operator's (`void parts::Install()`). + */ +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-range-for-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName @confidence`, one per call site. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +/** protocolbuffers/protobuf's conformance runner, trimmed; `FlagParser` is a decoy `SetVerbose`. */ +const CONFORMANCE = { + 'conformance/suite.h': [ + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'class ConformanceTestRunner {', + ' public:', + ' virtual ~ConformanceTestRunner() = default;', + '};', + 'class ConformanceTestSuite {', + ' public:', + ' void SetVerbose(bool verbose) { verbose_ = verbose; }', + ' std::string GetFailureListFlagName() { return failure_list_flag_name_; }', + ' bool RunSuite(ConformanceTestRunner* runner, std::string* output,', + ' const std::string& filename);', + ' private:', + ' bool verbose_ = false;', + ' std::string failure_list_flag_name_;', + '};', + 'class FlagParser {', + ' public:', + ' void SetVerbose(bool verbose) {}', + '};', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), + 'conformance/suite.cc': [ + '#include "conformance/suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'bool ConformanceTestSuite::RunSuite(ConformanceTestRunner* runner, std::string* output,', + ' const std::string& filename) {', + ' return true;', + '}', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), + 'conformance/runner.cc': [ + '#include ', + '#include ', + '#include "conformance/suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'int ForkPipeRunnerMain(int argc, char* argv[], const std::vector& suites) {', + ' bool verbose = false;', + ' for (ConformanceTestSuite *suite : suites) {', + ' std::string failure_list_filename;', + ' for (int arg = 1; arg < argc; ++arg) {', + ' if (strcmp(argv[arg], suite->GetFailureListFlagName().c_str()) == 0) {', + ' failure_list_filename = argv[arg];', + ' }', + ' }', + ' suite->SetVerbose(verbose);', + ' std::string output;', + ' ConformanceTestRunner* runner = nullptr;', + ' verbose = verbose && suite->RunSuite(runner, &output,', + ' failure_list_filename);', + ' }', + ' return 0;', + '}', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), +}; + +/** Two `Start`s no receiver name below points at, and a class whose member is named like a loop variable. */ +const GARAGE = { + 'garage/vehicle.h': [ + '#include ', + 'namespace garage {', + 'class Engine {', + ' public:', + ' void Start();', + ' int Torque() const;', + '};', + 'class Car {', + ' public:', + ' void Start();', + '};', + 'namespace parts {', + 'class Part {', + ' public:', + ' void Fit();', + '};', + 'void Install();', + '} // namespace parts', + '} // namespace garage', + '', + ].join('\n'), + 'garage/vehicle.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Engine::Start() {}', + 'int Engine::Torque() const { return 0; }', + 'void Car::Start() {}', + 'void parts::Part::Fit() {}', + '} // namespace garage', + '', + ].join('\n'), + 'garage/garage.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'class Garage {', + ' public:', + ' void Inspect(const std::vector& engines);', + ' void Service();', + ' private:', + ' Car* car;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/garage.cc': [ + '#include "garage/garage.h"', + 'namespace garage {', + 'void Garage::Inspect(const std::vector& engines) {', + ' for (Engine* car : engines) car->Start();', + '}', + 'void Garage::Service() {', + ' car->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), +}; + +describe('a C++ range-based for declares its loop variable', () => { + it('reproduction: protobuf\'s conformance runner calls the suite\'s methods through the typed path', async () => { + const cg = await indexed(CONFORMANCE); + try { + // The guess by the receiver's name lands on the same methods (0.65 / + // 0.7); the loop's declared type makes them certain. + expect(calls(cg, 'google::protobuf::ForkPipeRunnerMain')).toEqual([ + 'google::protobuf::ConformanceTestSuite::GetFailureListFlagName @0.9', + 'google::protobuf::ConformanceTestSuite::RunSuite @0.9', + 'google::protobuf::ConformanceTestSuite::SetVerbose @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('a loop variable that hides a member is the element, not the member', async () => { + const cg = await indexed(GARAGE); + try { + // Reading on past the loop reached the member `Car* car;` in the header. + expect(calls(cg, 'garage::Garage::Inspect')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('after the loop, and in another function, a same-named receiver is not the loop\'s variable', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/fleet.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Drive(Car* engine, const std::vector& engines) {', + ' for (Engine* engine : engines) {', + ' engine->Start();', + ' }', + ' engine->Start();', + '}', + 'void Wash(Car* engine, const std::vector& engines) {', + ' for (Engine* engine : engines) engine->Start();', + ' engine->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Drive')).toEqual(['garage::Car::Start @0.9', 'garage::Engine::Start @0.9']); + // A loop without braces ends with its one statement. + expect(calls(cg, 'garage::Wash')).toEqual(['garage::Car::Start @0.9', 'garage::Engine::Start @0.9']); + // Garage::Service's `car` is the member, though Inspect's loop above it names one `car`. + expect(calls(cg, 'garage::Garage::Service')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('reads references, pointers, qualified and global-scope types, and a header split over lines', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/shop.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void ByReference(std::vector& engines) {', + ' for (Engine& e : engines) e.Start();', + '}', + 'void ByConstReference(const std::vector& engines) {', + ' for (const Engine& e : engines) {', + ' if (e.Torque() > 0) {}', + ' }', + '}', + 'void StarOnName(const std::vector& engines) {', + ' for (Engine *e : engines)', + ' e->Start();', + '}', + 'void Qualified(const std::vector& list) {', + ' for (::garage::parts::Part* p : list) p->Fit();', + '}', + 'void SplitHeader(const std::vector& engines) {', + ' for (Engine* first_engine_in_the_list :', + ' engines) {', + ' first_engine_in_the_list->Start();', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::ByReference')).toEqual(['garage::Engine::Start @0.9']); + expect(calls(cg, 'garage::ByConstReference')).toEqual(['garage::Engine::Torque @0.9']); + expect(calls(cg, 'garage::StarOnName')).toEqual(['garage::Engine::Start @0.9']); + expect(calls(cg, 'garage::Qualified')).toEqual(['garage::parts::Part::Fit @0.9']); + expect(calls(cg, 'garage::SplitHeader')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a nested loop\'s call is inside the outer loop\'s body', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/nested.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Nested(const std::vector& list) {', + ' for (parts::Part* p : list) {', + ' for (int i = 0; i < 2; ++i) {', + ' if (i) { p->Fit(); }', + ' }', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Nested')).toEqual(['garage::parts::Part::Fit @0.9']); + } finally { + cg.close(); + } + }); +}); + +describe('what declares no loop variable', () => { + it('a `for` in a comment or a string literal', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/notes.cc': [ + '#include ', + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Commented(Car* car) {', + ' // for (Engine* car : engines) {', + ' car->Start();', + '}', + 'void Inline(Car* car) {', + ' /* for (Engine* car : engines) { */ car->Start();', + '}', + 'std::string Template(Car* car) {', + ' std::string code = "for (Engine* car : engines) {";', + ' car->Start();', + ' return code + "}";', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Commented')).toEqual(['garage::Car::Start @0.9']); + expect(calls(cg, 'garage::Inline')).toEqual(['garage::Car::Start @0.9']); + expect(calls(cg, 'garage::Template')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a bit-field', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/lot.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'struct Slot {', + ' unsigned car : 1;', + '};', + 'class Lot {', + ' public:', + ' void Park();', + ' private:', + ' Car* car;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/lot.cc': [ + '#include "garage/lot.h"', + 'namespace garage {', + 'void Lot::Park() {', + ' car->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + // The member `Car* car;`, not Slot's `unsigned car : 1;` above it. + expect(calls(cg, 'garage::Lot::Park')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a name followed by the scope operator', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/bay.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'class Bay {', + ' public:', + ' void Open();', + ' void Fit(const std::vector& list);', + ' private:', + ' Car* parts;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/bay.cc': [ + '#include "garage/bay.h"', + 'namespace garage {', + 'void parts::Install() {}', + 'void Bay::Open() {', + ' parts->Start();', + '}', + 'void Bay::Fit(const std::vector& list) {', + ' for (parts::Part* part : list) parts->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + // `void parts::Install()` declares no `parts`; the member does. + expect(calls(cg, 'garage::Bay::Open')).toEqual(['garage::Car::Start @0.9']); + // `parts::Part` declares the loop's `part`; the receiver `parts` is still the member. + expect(calls(cg, 'garage::Bay::Fit')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7d148d80d..d918753c2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8282,13 +8282,110 @@ function normalizeCppTypeName(typeName: string): string | null { // `Type*receiver`, `Type receiver`, etc., REQUIRING a declarator terminator // (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The // terminator rules out uses like `return receiver->m()` where the preceding -// token is a keyword, not a type. +// token is a keyword, not a type. A range-based for's `Type receiver :` is read +// by cppRangeForDeclaration instead. function buildDeclaratorRegex(escapedReceiver: string): RegExp { return new RegExp( `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`, ); } +// A range-based for's declaration, `for (const Foo& name :`, up to the single +// colon (not `::`) that ends it. What may precede the type — `const`, a +// global-scope `::` — is never an initializer, a call or a member access. +const CPP_RANGE_FOR_DECLARATION = /\bfor\s*\(([^;=(){}?."']*?)\b([A-Za-z_]\w*)\s*:(?!:)/g; +// The type a declaration ends with, read as the declarator regex reads it. +const CPP_DECLARED_TYPE_TAIL = /([A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?(?:\s*[*&]+)?)\s*$/; + +/** + * The type `line`'s range-based for declares `receiverName` with — + * `for (ConformanceTestSuite *suite : suites)` — and where the type and the + * header's `(` are. The declarator regex never reads one: a loop's name is + * followed by its `:`. (Neither is a bit-field, `unsigned car : 1;`, which no + * method is called on.) + */ +function cppRangeForDeclaration(line: string, receiverName: string): { type: string; at: number; paren: number } | null { + if (!line.includes('for') || /^\s*(?:\/\/|\/\*|\*)/.test(line)) return null; + for (const m of line.matchAll(CPP_RANGE_FOR_DECLARATION)) { + if (m[2] !== receiverName) continue; + const paren = m.index! + m[0].indexOf('('); + const tail = m[1]!.match(CPP_DECLARED_TYPE_TAIL); + return tail ? { type: tail[1]!, at: paren + 1 + tail.index!, paren } : null; + } + return null; +} + +/** Where the C or C++ string or character literal opening at `text[start]` closes (the line's end if it doesn't). */ +function cppLiteralEnd(text: string, start: number): number { + for (let i = start + 1; i < text.length; i++) { + if (text[i] === '\\') i++; + else if (text[i] === text[start]) return i; + } + return text.length; +} + +/** + * Is column `useColumn` of line `useLine` inside the body of the `for` whose + * header opens at `lines[line][paren]`? A range-based for's variable is in + * scope there and nowhere else: after the loop, or in a later function, the + * same name is some other variable. The body runs to the `}` closing a braced + * body, or to the end of its one statement — taken to be the first `;` or + * closing `}` at its own depth, so an `else` after it is not counted. + * Comments and literals are skipped, and a `for` inside one (commented-out + * code, a code generator's template) has no body. + */ +function cppForBodyEncloses(lines: readonly string[], line: number, paren: number, useLine: number, useColumn: number): boolean { + let phase: 'before' | 'header' | 'gap' | 'block' | 'statement' = 'before'; + let depth = 0; + let comment = false; + for (let l = line; l <= useLine && l < lines.length; l++) { + const text = lines[l]!; + const end = l === useLine ? Math.min(useColumn, text.length) : text.length; + for (let c = 0; c < end; c++) { + const ch = text[c]!; + if (comment) { + if (ch === '*' && text[c + 1] === '/') { + comment = false; + c++; + } + continue; + } + if (ch === '/' && text[c + 1] === '/') break; + if (ch === '/' && text[c + 1] === '*') { + comment = true; + c++; + continue; + } + // (A `'` after a digit separates digits: `1'000'000`.) + if (ch === '"' || (ch === '\'' && !/\d/.test(text[c - 1] ?? ''))) { + c = cppLiteralEnd(text, c); + continue; + } + if (phase === 'before') { + if (l === line && c === paren) { + phase = 'header'; + depth = 1; + } + continue; + } + if (phase === 'header') { + if (ch === '(') depth++; + else if (ch === ')' && --depth === 0) phase = 'gap'; + continue; + } + if (phase === 'gap') { + if (/\s/.test(ch)) continue; + phase = ch === '{' ? 'block' : 'statement'; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']') depth--; + else if (ch === '}' && --depth <= 0) return false; + else if (ch === ';' && depth <= 0 && phase === 'statement') return false; + } + } + return phase !== 'before' && phase !== 'header'; +} + /** What C++ receiver inference made of a receiver's declared type. */ interface CppReceiverDeclaration { /** @@ -8416,6 +8513,18 @@ function inferCppReceiverType( const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, line, declaratorMatch.index ?? 0, ref, context); return cppDeclaredType(declaratorMatch[1]!, normalized, inCallerScope, ref, context, found); } + } else { + // `for (ConformanceTestSuite *suite : suites)`, when the call is in the + // loop's body. A loop over `auto` elements has no initializer to read, + // so the scan goes on as for an `auto` local whose initializer is unreadable. + const loopVar = cppRangeForDeclaration(line, receiverName); + if (loopVar && cppForBodyEncloses(lines, i, loopVar.paren, callLineIndex, ref.column)) { + const normalized = normalizeCppTypeName(loopVar.type); + if (normalized && normalized !== 'auto') { + const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, line, loopVar.at, ref, context); + return cppDeclaredType(loopVar.type, normalized, inCallerScope, ref, context, found); + } + } } } From e0a2469815688be40d4bd99b095e74709b76a7e8 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 06:46:05 -0500 Subject: [PATCH 2/2] test(cpp): a brace in a comment or string inside a range-for body does not end the loop Receiver inference now reads comment lines blanked (#2413), so the commented-out loops in the range-for tests no longer reach the loop-body scan; this case keeps its own comment and literal handling covered. Co-Authored-By: Claude Opus 5.5 --- __tests__/cpp-range-for-receiver.test.ts | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/__tests__/cpp-range-for-receiver.test.ts b/__tests__/cpp-range-for-receiver.test.ts index 1eec4539f..67da5c2f9 100644 --- a/__tests__/cpp-range-for-receiver.test.ts +++ b/__tests__/cpp-range-for-receiver.test.ts @@ -283,6 +283,33 @@ describe('a C++ range-based for declares its loop variable', () => { } }); + it('a brace in a comment or a string literal does not end the loop', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/braces.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Braces(const std::vector& engines) {', + ' for (Engine* e : engines) {', + ' // A comment\'s } is not the loop\'s.', + ' const char* close = "}";', + ' /* nor is } this one', + ' } or this */', + ' char brace = \'}\';', + ' e->Start();', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Braces')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + it('a nested loop\'s call is inside the outer loop\'s body', async () => { const cg = await indexed({ ...GARAGE,