diff --git a/AGENTS.md b/AGENTS.md index 88d0dfb26..ef1ba0682 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the **It must stay light to load:** a read command, a query worker and the prompt hook all load it, and never index. What only indexing needs (extraction, resolution, the watcher) goes through `engine()` (`src/lazy-engine.ts`, listed in `src/engine.ts`), never an import of its own; `__tests__/lazy-engine.test.ts` fails otherwise. - `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend). - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. -- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, CodeIgniter 3, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` and for the routes a proxy's `config.matcher` accepts, read by `next-proxy.ts` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). +- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, CodeIgniter 3, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` and for the routes a proxy's `config.matcher` accepts, read by `next-proxy.ts` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `cpp-template-instantiations.ts` is the pass for a C++ call through a template parameter, onto each class the project writes as the argument (read from `Name`, `f()`, a deduced call or a gtest type list), capped per parameter; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). - `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws), `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here), `file-cycles.ts` (circular imports, in `codegraph cycles` and the viewer's map). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree. - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. @@ -158,7 +158,7 @@ Two functions in `src/mcp/tools.ts` scale explore with indexed file count. This ### Dynamic-dispatch coverage — the flow must EXIST in the graph end-to-end -Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), **React Native native→JS events** (`sendEvent(withName:)` / JVM `emit` → the `addListener` handler, named or inline, `rn-event-channel`), django ORM descriptor. The JS→native direction is a *resolver* (`frameworks/react-native.ts`: `RCT_EXPORT_METHOD`, `RCT_EXTERN_MODULE` Swift shims, TurboModules), which trusts receiver evidence — an alias bound to `NativeModules.X` — over the import resolver. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail. +Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), **React Native native→JS events** (`sendEvent(withName:)` / JVM `emit` → the `addListener` handler, named or inline, `rn-event-channel`), django ORM descriptor, **C++ template arguments** (`Txn::Put` or `shard.Clear()` in a template → that member of each class the project instantiates it with). The JS→native direction is a *resolver* (`frameworks/react-native.ts`: `RCT_EXPORT_METHOD`, `RCT_EXTERN_MODULE` Swift shims, TurboModules), which trusts receiver evidence — an alias bound to `NativeModules.X` — over the import resolver. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail. **Principle: partial coverage is WORSE than none.** Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Measured on excalidraw: react-render alone *raised* reads to 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow. diff --git a/CHANGELOG.md b/CHANGELOG.md index f9854bde3..7ace81b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -260,6 +260,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, C++ and Objective-C, a struct, class or union now links to the types its members are declared with, whether held by value, by pointer, in an array, through a function pointer or inside a template like `std::vector`, and a `typedef` or `using` alias links to the type it names, so impact on a type reaches every struct that holds one, like redis's `redisServer` for `pause_event`. A member's type is the one C++ name lookup finds from the class, so a builtin, a macro or a function of the same name links nothing, and a type a macro renames is what the macro names where the file sees it, like hiredis's `#define sds hisds` in redis-cli.c. Re-index C, C++ and Objective-C projects after upgrading. - In C and C++, a function-pointer table's handler, like `.read = impl_read` in an ops struct, no longer links to a Python or Rust function that only shares its name, and such a function no longer takes the place of the C function the table names. Re-index C and C++ projects that also hold Python or Rust after upgrading. Thanks @bompus. +- In C++, a call through a template parameter now links to that member of each class the project writes as the template's argument: `Txn::Put(…)` in a `template class Mixin`, `shard.Clear()` on a `CacheShard& shard`, a function template's `Traits::Make()`, or `TypeParam::Make()` in a googletest typed test. `codegraph_explore`, callers and impact now follow a flow through the template, and the hop says which argument it came from. A template written with a great many classes, such as a container, is left unlinked. - 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. An alias of a type written from the global scope names that global type, also inside a namespace of the same name: protocolbuffers/protobuf's `using Arena = ::upb::Arena;` in `hpb::internal::backend::upb`, so hpb's `arena_.Fuse(…)` on a `backend::Arena` now reaches `upb::Arena::Fuse` instead of the calling method itself. Re-index C++ projects after upgrading. - In C++, indexing no longer fails with "Maximum call stack size exceeded" on a call whose arguments call the same overloaded method, like darts-clone's `extras(extras(id).prev()).set_next(…)` on a class with a `const` and a non-`const` `extras(id)`. Choosing the overload read its arguments, which chose the same overload again without end, and the whole index run failed. Such a call now takes the first overload when the choice comes back to itself, as one whose argument types are unknown does. Thanks @JosefAschauer. - In C++, a call on a variable, parameter or member declared with template arguments nested inside its template arguments, like `autovector>` or `Striped>`, now links to the method of the class it is declared as. Before, the nested arguments hid that class: in RocksDB, `files_marked_for_compaction_.clear()` on such an `autovector` linked to an unrelated `CompactionInputFiles::clear` that only shares words with the variable's name, and `mutex_.Get(key)` on a `Striped` linked to nothing. Re-index C++ projects after upgrading. diff --git a/__tests__/cpp-template-instantiations.test.ts b/__tests__/cpp-template-instantiations.test.ts new file mode 100644 index 000000000..673365d9f --- /dev/null +++ b/__tests__/cpp-template-instantiations.test.ts @@ -0,0 +1,1249 @@ +/** + * A C++ call through a template parameter links to that member of each class + * the project writes as the template's argument: rocksdb's `Txn::PutEntity(…)` + * in `template class SecondaryIndexMixin`, `Traits::ClearOutput` + * in a function template given two local `Traits`, and a typed test's + * `TypeParam::KeyGen`. No lookup at the call can answer these, so they are + * synthesized from the instantiations, one edge per class. + */ +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 { describeSynthesizedHop } from '../src/graph/synthesized-hop'; +import { hasSynthesisPattern } from '../src/resolution/callback-synthesizer'; +import { CPP_TEMPLATE_ARGUMENT_CAP } from '../src/resolution/cpp-template-instantiations'; +import type { Edge } from '../src/types'; + +const roots: string[] = []; +const open: CodeGraph[] = []; +afterEach(() => { + for (const cg of open.splice(0)) cg.close(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +const lines = (...l: string[]): string => [...l, ''].join('\n'); + +function project(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-template-')); + roots.push(root); + write(root, files); + return root; +} + +function write(root: string, files: Record): void { + 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); + } +} + +async function opened(root: string): Promise { + const cg = await CodeGraph.init(root, { index: true }); + open.push(cg); + return cg; +} + +const indexed = (files: Record): Promise => opened(project(files)); + +interface Row { + source: string; + target: string; + targetKind: string; + kind: string; + line: number; + via: string; + argument: string; + registeredAt: string; + provenance: string; + fnRef: number | null; +} + +/** Every edge the pass made, as text, in insertion order. */ +function templateEdges(cg: CodeGraph): Row[] { + const db = (cg as unknown as { db: { db: { prepare(sql: string): { all(): Row[] } } } }).db.db; + return db.prepare( + `SELECT s.qualified_name source, t.qualified_name target, t.kind targetKind, e.kind kind, e.line line, e.provenance provenance, + json_extract(e.metadata, '$.via') via, json_extract(e.metadata, '$.argument') argument, json_extract(e.metadata, '$.registeredAt') registeredAt, + json_extract(e.metadata, '$.fnRef') fnRef + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.synthesizedBy') = 'cpp-template-argument' ORDER BY e.id` + ).all(); +} + +/** The targets the pass linked from `source`, sorted. */ +function reached(cg: CodeGraph, source: string): string[] { + return templateEdges(cg).filter((e) => e.source === source).map((e) => e.target).sort(); +} + +const TXN = lines( + 'class BaseTxn {', + ' public:', + ' void Commit() {}', + '};', + 'class PlainTxn : public BaseTxn {', + ' public:', + ' void Put(int k) {}', + ' void Put(int k, int v) {}', + ' static void Make() {}', + '};', + 'class FancyTxn : public BaseTxn {', + ' public:', + ' void Put(int k) {}', + ' static void Make() {}', + '};', + 'class NoPutTxn {};', +); + +/** `Mixin` with one call through its parameter, in a header. */ +const MIXIN = lines( + '#include "txn.h"', + 'template ', + 'class Mixin : public Txn {', + ' public:', + ' void Add(int k) { Txn::Put(k); }', + ' void Finish() { Txn::Commit(); }', + ' static void Build() { Txn::Make(); }', + '};', +); + +const PUT_PLAIN = 'PlainTxn::Put'; + +describe('the forms of a written instantiation', () => { + const uses = async (use: string): Promise => indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines('#include "mixin.h"', use), + }); + + it('links to the argument of the one instantiation, the rocksdb SecondaryIndexMixin shape', async () => { + const cg = await uses('void Run() { auto* m = new Mixin(); m->Add(1); }'); + expect(reached(cg, 'Mixin::Add')).toEqual([PUT_PLAIN]); + expect(reached(cg, 'Mixin::Build')).toEqual(['PlainTxn::Make']); + const [edge] = templateEdges(cg).filter((e) => e.source === 'Mixin::Add'); + expect(edge).toMatchObject({ kind: 'calls', via: 'Txn', argument: 'PlainTxn', registeredAt: 'use.cc:2', provenance: 'heuristic', line: 5 }); + }); + + it('chooses the overload the call makes', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'pair.h': lines('#include "txn.h"', 'template ', 'struct Pair {', ' void Both(int a, int b) { T::Put(a, b); }', '};'), + 'use.cc': lines('#include "pair.h"', 'Pair pair;'), + }); + const [edge] = templateEdges(cg); + expect(edge?.target).toBe(PUT_PLAIN); + const db = (cg as unknown as { db: { db: { prepare(sql: string): { all(): Array<{ start_line: number }> } } } }).db.db; + const line = db.prepare(`SELECT n.start_line FROM edges e JOIN nodes n ON n.id = e.target WHERE json_extract(e.metadata, '$.synthesizedBy') = 'cpp-template-argument'`).all(); + expect(line).toEqual([{ start_line: 8 }]); + }); + + it.each([ + ['a base clause', 'class Logged : public Mixin {};'], + ['a variable', 'Mixin global;'], + ['a member', 'class Holder { Mixin held_; };'], + ['a pointer to it', 'Mixin* maybe = nullptr;'], + ['an argument of another template', 'std::vector> many;'], + ['a using alias', 'using Aliased = Mixin;'], + ['a typedef', 'typedef Mixin Aliased;'], + ['an explicit instantiation', 'template class Mixin;'], + ['an extern explicit instantiation', 'extern template class Mixin;'], + ['a static member call', 'void Run() { Mixin::Build(); }'], + ['the name written with a namespace', 'namespace other { ::Mixin global; }'], + ['a list broken over lines', 'Mixin<\n PlainTxn\n> global;'], + ])('reads %s', async (_form, use) => { + const cg = await uses(use); + expect(reached(cg, 'Mixin::Add')).toEqual([PUT_PLAIN]); + }); + + it('reads past the arrow of a trailing return type', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'bound.h': lines('#include "txn.h"', 'template ', 'struct Bound {', ' void Run() { T::Make(); }', '};'), + 'use.cc': lines('#include "bound.h"', 'Boundint, PlainTxn> bound;'), + }); + expect(reached(cg, 'Bound::Run')).toEqual(['PlainTxn::Make']); + }); + + it('links an explicit function template argument', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'run.cc': lines( + '#include "txn.h"', + 'template ', + 'void Build() { T::Make(); }', + 'void Run() {', + ' Build();', + ' Build();', + '}', + ), + }); + expect(reached(cg, 'Build')).toEqual(['FancyTxn::Make', 'PlainTxn::Make']); + expect(templateEdges(cg).map((e) => e.registeredAt).sort()).toEqual(['run.cc:5', 'run.cc:6']); + }); + + it('links a member function template and a class template method each to its own head', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'host.h': lines( + '#include "txn.h"', + 'template ', + 'class Host {', + ' public:', + ' template ', + ' void Both() { A::Make(); B::Make(); }', + '};', + ), + 'use.cc': lines('#include "host.h"', 'void Run() {', ' Host h;', ' h.Both();', '}'), + }); + expect(reached(cg, 'Host::Both')).toEqual(['FancyTxn::Make', 'PlainTxn::Make']); + }); + + it('reads the parameter of a member defined outside its class, by position', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'pair.h': lines( + '#include "txn.h"', + 'template ', + 'class Pair {', + ' public:', + ' void Go();', + ' template void More();', + '};', + 'template ', + 'void Pair::Go() { B::Make(); }', + 'template ', + 'template ', + 'void Pair::More() { A::Make(); C::Make(); }', + ), + 'use.cc': lines('#include "pair.h"', 'void Run() {', ' Pair<1, PlainTxn, FancyTxn> p;', ' p.More();', '}'), + }); + expect(reached(cg, 'Pair::Go')).toEqual(['FancyTxn::Make']); + expect(reached(cg, 'Pair::More')).toEqual(['PlainTxn::Make']); + }); + + it('reads the parameter of a class around the caller, and of a function around a local class', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'all.cc': lines( + '#include "txn.h"', + 'template ', + 'class Outer {', + ' public:', + ' class Inner {', + ' public:', + ' void Go() { T::Make(); }', + ' };', + '};', + 'Outer outer;', + 'template ', + 'void Run() {', + ' struct Local {', + ' void Go() { T::Make(); }', + ' };', + ' auto later = [] { T::Make(); };', + '}', + 'void Start() { Run(); }', + ), + }); + expect(reached(cg, 'Outer::Inner::Go')).toEqual(['PlainTxn::Make']); + expect(reached(cg, 'Run::Local::Go')).toEqual(['FancyTxn::Make']); + expect(reached(cg, 'Run')).toEqual(['FancyTxn::Make']); + }); + + it('reads the parameter of a nested class defined outside its template', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'list.h': lines( + '#include "txn.h"', + 'template ', + 'class List {', + ' public:', + ' struct Node;', + '};', + 'template ', + 'struct List::Node {', + ' void Go() { T::Make(); }', + '};', + ), + 'use.cc': lines('#include "list.h"', 'List list;'), + }); + expect(templateEdges(cg).map((e) => e.target)).toEqual(['PlainTxn::Make']); + }); + + it('reads a parameter pack', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'pack.h': lines('#include "txn.h"', 'template ', 'struct All {', ' static void Go() { (Ts::Make(), ...); }', '};'), + 'use.cc': lines('#include "pack.h"', 'All<1, PlainTxn, FancyTxn> all;'), + }); + expect(reached(cg, 'All::Go')).toEqual(['FancyTxn::Make', 'PlainTxn::Make']); + }); + + it('takes a default the instantiation does not write', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'def.h': lines('#include "txn.h"', 'template ', 'struct Def {', ' void Go() { B::Commit(); B::Put(1); }', '};'), + 'use.cc': lines('#include "def.h"', 'Def plain;', 'Def written;'), + }); + expect(reached(cg, 'Def::Go')).toEqual(['BaseTxn::Commit', 'FancyTxn::Put', PUT_PLAIN]); + }); + + it('looks the template up from a namespace, with no declaration around the site', async () => { + const cg = await indexed({ + 'all.h': lines( + 'namespace ns {', + 'struct Item { static void Go() {} };', + 'template struct Box { void Run() { T::Go(); } };', + '}', + ), + 'other.h': lines('namespace other {', 'struct Item { static void Go() {} };', '}'), + 'use.cc': lines('#include "all.h"', '#include "other.h"', 'namespace ns {', 'template class Box;', '}'), + }); + expect(reached(cg, 'ns::Box::Run')).toEqual(['ns::Item::Go']); + }); + + it('counts a namespace a macro opens', async () => { + const cg = await indexed({ + 'macros.h': lines('#define NS_BEGIN namespace ns {', '#define NS_END }'), + 'all.h': lines( + '#include "macros.h"', + 'NS_BEGIN', + 'struct Item { static void Go() {} };', + 'template struct Box { void Run() { T::Go(); } };', + 'NS_END', + ), + 'use.cc': lines('#include "all.h"', 'NS_BEGIN', 'template class Box;', 'NS_END'), + }); + expect(templateEdges(cg).map((e) => e.registeredAt)).toEqual(['use.cc:3']); + }); + + it('reads a function pointer to a member through a parameter', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'use.cc': lines( + '#include "txn.h"', + 'template ', + 'struct Each {', + ' void Go() { Run(&T::Make); }', + ' void Run(void (*fn)()) { fn(); }', + '};', + 'Each each;', + ), + }); + const [edge] = templateEdges(cg); + expect(edge).toMatchObject({ kind: 'references', source: 'Each::Go', target: 'PlainTxn::Make', fnRef: 1 }); + }); +}); + +describe('a call on a value of the parameter\'s type', () => { + const body = (code: string): Record => ({ + 'shards.h': lines( + 'struct Inner { void Deep() {} };', + 'struct FastShard { using Part = Inner; void Clear() {} };', + 'struct SlowShard { using Part = Inner; void Clear() {} };', + ), + 'cache.h': lines( + '#include "shards.h"', + 'template ', + 'class Cache {', + ' public:', + code, + ' private:', + ' Shard own_;', + ' Shard* many_;', + '};', + ), + 'use.cc': lines('#include "cache.h"', 'Cache fast;', 'Cache slow;'), + }); + const CLEARS = ['FastShard::Clear', 'SlowShard::Clear']; + + it.each([ + ['a parameter by reference', 'void Run(Shard& shard) { shard.Clear(); }'], + ['a parameter by const reference', 'void Run(const Shard& shard) { shard.Clear(); }'], + ['a parameter by pointer', 'void Run(Shard* shard) { shard->Clear(); }'], + ['a local', 'void Run() { Shard shard; shard.Clear(); }'], + ['a member', 'void Run() { own_.Clear(); }'], + ['a pointer member', 'void Run() { many_->Clear(); }'], + ])('reads %s', async (_form, code) => { + const cg = await indexed(body(code)); + expect(reached(cg, 'Cache::Run')).toEqual(CLEARS); + }); + + it('reads a member of the parameter\'s type, unless a local of another type hides it', async () => { + const hidden = await indexed(body('void Run() { Inner own_; own_.Clear(); }')); + expect(templateEdges(hidden)).toEqual([]); + }); + + it('reads a member type of the parameter', async () => { + const cg = await indexed(body('void Run() { typename Shard::Part part; part.Deep(); }')); + expect(reached(cg, 'Cache::Run')).toEqual(['Inner::Deep']); + }); + + it('reads an alias of a member type', async () => { + const cg = await indexed(body('using Part = typename Shard::Part;\n void Run(Part& part) { part.Deep(); }')); + expect(reached(cg, 'Cache::Run')).toEqual(['Inner::Deep']); + }); + + it('keeps out an arrow on a value: that is the argument\'s own operator', async () => { + const cg = await indexed(body('void Run(Shard& shard) { shard->Clear(); }')); + expect(templateEdges(cg)).toEqual([]); + }); + + it('keeps out a variable of another type', async () => { + const cg = await indexed(body('void Run(Other& shard) { shard.Clear(); }')); + expect(templateEdges(cg)).toEqual([]); + }); + + it('leaves a call a resolver links alone', async () => { + const cg = await indexed(body('void Run(Inner& inner) { inner.Deep(); }')); + expect(templateEdges(cg)).toEqual([]); + expect(cg.getCallees(cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Cache::Run')!.id).map((r) => r.node.qualifiedName)).toEqual(['Inner::Deep']); + }); +}); + +describe('a deduced argument', () => { + const deduced = (use: string): Promise => indexed({ + 'txn.h': TXN, + 'run.cc': lines( + '#include "txn.h"', + 'template void ByValue(T x) { T::Make(); }', + 'template void ByRef(const T& x) { T::Make(); }', + 'template void ByPointer(T* x) { T::Make(); }', + 'template void ByHolder(std::vector* x) { T::Make(); }', + use, + ), + }); + + it('reads the type of a declared argument, whole', async () => { + const cg = await deduced('void Run() { PlainTxn p; ByValue(p); ByRef(p); }'); + expect(reached(cg, 'ByValue')).toEqual(['PlainTxn::Make']); + expect(reached(cg, 'ByRef')).toEqual(['PlainTxn::Make']); + }); + + it('reads what a pointer points at', async () => { + const cg = await deduced('void Run() { FancyTxn f; ByPointer(&f); FancyTxn* q = nullptr; ByPointer(q); }'); + expect(reached(cg, 'ByPointer')).toEqual(['FancyTxn::Make']); + }); + + it('follows an argument whose type is the caller\'s own parameter', async () => { + const cg = await deduced('template void Outer(U u) { ByRef(u); }\nvoid Run() { Outer(PlainTxn()); }'); + expect(reached(cg, 'ByRef')).toEqual(['PlainTxn::Make']); + }); + + it('gives no edge for a literal, an unknown name or a type the parameter only holds', async () => { + const cg = await deduced('void Run(int i) { ByValue(1); ByRef(nowhere); ByRef(i); std::vector v; ByHolder(&v); PlainTxn p; ByPointer(p); }'); + expect(templateEdges(cg)).toEqual([]); + }); + + it('keeps a call that gives a type beside one that gives a literal', async () => { + const cg = await deduced('void Run() { ByValue(1); PlainTxn p; ByValue(p); }'); + expect(reached(cg, 'ByValue')).toEqual(['PlainTxn::Make']); + }); + + it('takes the written argument over the deduced one', async () => { + const cg = await deduced('void Run() { PlainTxn p; ByValue(p); }'); + expect(reached(cg, 'ByValue')).toEqual(['FancyTxn::Make']); + }); +}); + +describe('an instantiation inside another template', () => { + it('follows the argument to the outer template, through a chain', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'chain.h': lines( + '#include "mixin.h"', + 'template ', + 'class Middle : public Mixin {};', + 'template ', + 'using Short = Middle;', + 'template ', + 'void Outer() { Short s; }', + ), + 'use.cc': lines('#include "chain.h"', 'void Run() { Outer(); }', 'Short direct;'), + }); + expect(reached(cg, 'Mixin::Add')).toEqual(['FancyTxn::Put', PUT_PLAIN]); + expect(templateEdges(cg).find((e) => e.target === PUT_PLAIN)?.registeredAt).toBe('use.cc:2'); + }); + + it('links no call to itself', async () => { + const cg = await indexed({ + 'all.cc': lines( + 'template ', + 'struct Base { static void Make() { D::Make(); } };', + 'struct Plain : public Base {};', + 'struct Own : public Base { static void Make() {} };', + ), + }); + expect(reached(cg, 'Base::Make')).toEqual(['Own::Make']); + }); + + it('follows an argument written const', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'inner.h': lines( + '#include "txn.h"', + 'template struct Inner { void Go() { T::Make(); } };', + 'template struct Outer { Inner held; };', + ), + 'use.cc': lines('#include "inner.h"', 'Outer outer;'), + }); + expect(reached(cg, 'Inner::Go')).toEqual(['PlainTxn::Make']); + }); + + it('stops at a cycle, keeping what the rest of the walk finds', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'loop.h': lines( + '#include "txn.h"', + 'template class B;', + 'template ', + 'class A {', + ' public:', + ' void Go() { T::Make(); }', + ' B* next_;', + '};', + 'template ', + 'class B {', + ' A* next_;', + ' A>* self_;', + '};', + ), + 'use.cc': lines('#include "loop.h"', 'B start;'), + }); + expect(reached(cg, 'A::Go')).toEqual(['PlainTxn::Make']); + }); + + it.each([ + [4, ['PlainTxn::Make']], + [5, []], + ])('follows an argument through %i templates at most, and stops short of a wrong answer', async (depth, targets) => { + const chain = Array.from({ length: depth }, (_, i) => lines(`template class C${i} : public C${i + 1} {};`)).reverse(); + const cg = await indexed({ + 'txn.h': TXN, + 'chain.h': lines( + '#include "txn.h"', + `template class C${depth} { public: void Go() { T::Make(); } };`, + ...chain, + ), + 'use.cc': lines('#include "chain.h"', 'C0 deep;'), + }); + expect(reached(cg, `C${depth}::Go`)).toEqual(targets); + }); +}); + +describe('gtest typed tests', () => { + const RIBBON = lines( + '#include ', + 'struct SmallGen { SmallGen(int a, int b) {} };', + 'struct BigGen { BigGen(int a, int b) {} };', + 'struct Base { using KeyGen = BigGen; static int Chance() { return 1; } };', + 'struct Small : public Base { using KeyGen = SmallGen; static int Chance() { return 2; } };', + 'struct Other : public Base {};', + 'template ', + 'class RibbonTest : public ::testing::Test {};', + 'using Listed = ::testing::Types<', + ' Small,', + ' Other>;', + 'TYPED_TEST_SUITE(RibbonTest, Listed);', + 'TYPED_TEST(RibbonTest, Works) {', + ' using KeyGen = typename TypeParam::KeyGen;', + ' KeyGen one(1, 2);', + ' int c = TypeParam::Chance();', + '}', + ); + + it('links TypeParam to each listed type, inherited or its own', async () => { + const cg = await indexed({ 'ribbon_test.cc': RIBBON }); + const edges = templateEdges(cg); + expect(edges.filter((e) => e.kind === 'calls' && e.target.endsWith('Chance')).map((e) => e.target).sort()).toEqual(['Base::Chance', 'Small::Chance']); + expect(edges.filter((e) => e.kind === 'instantiates').map((e) => e.target).sort()).toEqual(['Base::KeyGen', 'Small::KeyGen']); + expect(edges.filter((e) => e.kind === 'instantiates').every((e) => e.targetKind === 'type_alias')).toBe(true); + expect(edges.find((e) => e.target === 'Small::KeyGen')).toMatchObject({ via: 'TypeParam', argument: 'Small', registeredAt: 'ribbon_test.cc:10' }); + }); + + it('links the constructor of what each member alias names', async () => { + const cg = await indexed({ 'ribbon_test.cc': RIBBON }); + expect(templateEdges(cg).filter((e) => /Gen$/.test(e.target) && e.targetKind === 'method').map((e) => e.target).sort()) + .toEqual(['BigGen::BigGen', 'SmallGen::SmallGen']); + }); + + it.each([ + ['TYPED_TEST_CASE', 'TYPED_TEST_CASE(T1, Listed);', 'TYPED_TEST(T1, Works)'], + ['a list written in the macro', 'TYPED_TEST_SUITE(T1, ::testing::Types);', 'TYPED_TEST(T1, Works)'], + ['a typedef of the list', 'typedef ::testing::Types Spare; TYPED_TEST_SUITE(T1, Spare);', 'TYPED_TEST(T1, Works)'], + ['a parameterized suite', 'INSTANTIATE_TYPED_TEST_SUITE_P(Pre, T1, Listed);', 'TYPED_TEST_P(T1, Works)'], + ['the older parameterized macro', 'INSTANTIATE_TYPED_TEST_CASE_P(Pre, T1, Listed);', 'TYPED_TEST_P(T1, Works)'], + ])('reads %s', async (_form, suite, test) => { + const cg = await indexed({ + 'a_test.cc': lines( + '#include ', + 'struct A { static int Go() { return 1; } };', + 'template class T1 : public ::testing::Test { public: int Own() { return T::Go(); } };', + 'using Listed = ::testing::Types;', + suite, + `${test} { TypeParam::Go(); }`, + ), + }); + const targets = templateEdges(cg).map((e) => e.target); + expect(targets).toContain('A::Go'); + expect(reached(cg, 'T1::Own')).toEqual(['A::Go']); + }); + + it('reads a typed suite whose tests live in a header', async () => { + const cg = await indexed({ + 'suite.h': lines( + 'template class Suite : public ::testing::Test {};', + 'TYPED_TEST_SUITE_P(Suite);', + 'TYPED_TEST_P(Suite, Works) { TypeParam::Go(); }', + ), + 'a_test.cc': lines( + '#include "suite.h"', + 'struct A { static int Go() { return 1; } };', + 'using Listed = ::testing::Types;', + 'INSTANTIATE_TYPED_TEST_SUITE_P(My, Suite, Listed);', + ), + }); + expect(templateEdges(cg).map((e) => e.target)).toEqual(['A::Go']); + }); + + it('follows TypeParam into a helper the test instantiates', async () => { + const cg = await indexed({ + 'a_test.cc': lines( + '#include ', + 'struct A { static int Go() { return 1; } };', + 'template void Helper() { T::Go(); }', + 'template class T1 : public ::testing::Test {};', + 'TYPED_TEST_SUITE(T1, ::testing::Types);', + 'TYPED_TEST(T1, Works) { Helper(); }', + ), + }); + expect(reached(cg, 'Helper')).toEqual(['A::Go']); + }); +}); + +describe('what an argument is', () => { + it('finds a class local to the function that writes it, one per function', async () => { + const cg = await indexed({ + 'batch.cc': lines( + 'namespace ns {', + 'class Batch {', + ' public:', + ' template void Impl();', + ' void Get();', + ' void GetEntity();', + '};', + 'template ', + 'void Batch::Impl() { Traits::Clear(); }', + 'void Batch::Get() {', + ' struct Traits { static void Clear() {} };', + ' Impl();', + '}', + 'void Batch::GetEntity() {', + ' struct Traits { static void Clear() {} };', + ' Impl();', + '}', + '} // namespace ns', + ), + }); + expect(templateEdges(cg).map((e) => [e.source, e.registeredAt])).toEqual([ + ['ns::Batch::Impl', 'batch.cc:12'], + ['ns::Batch::Impl', 'batch.cc:16'], + ]); + expect(reached(cg, 'ns::Batch::Impl')).toHaveLength(2); + }); + + it('follows an alias of the class, at namespace scope and in the function', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines( + '#include "mixin.h"', + 'namespace ns { using Named = PlainTxn; }', + 'ns::Named* unused;', + 'Mixin global;', + 'void Run() {', + ' using Local = FancyTxn;', + ' Mixin local;', + '}', + ), + }); + expect(reached(cg, 'Mixin::Add')).toEqual(['FancyTxn::Put', PUT_PLAIN]); + }); + + it('looks an argument up from where it is written', async () => { + const cg = await indexed({ + 'a.h': lines('namespace a { struct Item { static void Go() {} }; }'), + 'b.h': lines('namespace b { struct Item { static void Go() {} }; }'), + 'box.h': lines('template ', 'struct Box { void Run() { T::Go(); } };'), + 'use.cc': lines('#include "a.h"', '#include "b.h"', '#include "box.h"', 'namespace b { Box here; }'), + }); + expect(reached(cg, 'Box::Run')).toEqual(['b::Item::Go']); + }); + + it('takes no class of a scope the site does not name', async () => { + const cg = await indexed({ + 'txn.h': lines(TXN, 'namespace deep { struct Hidden { static void Make() {} }; }'), + 'box.h': lines('#include "txn.h"', 'template ', 'struct Box { void Run() { T::Make(); } };'), + 'use.cc': lines('#include "box.h"', 'Box b;'), + }); + expect(templateEdges(cg)).toEqual([]); + }); + + it('gives an outside argument no edge, and keeps the others', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines( + '#include "mixin.h"', + '#include ', + 'Mixin s;', + 'Mixin i;', + 'Mixin p;', + ), + }); + expect(reached(cg, 'Mixin::Add')).toEqual([PUT_PLAIN]); + }); + + it('gives a pointer or a reference no edge: no such instantiation has the member', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines('#include "mixin.h"', 'Mixin p;', 'Mixin r;', 'Mixin c;'), + }); + expect(reached(cg, 'Mixin::Add')).toEqual(['FancyTxn::Put']); + }); + + it('links a member a class inherits, and none it lacks', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines('#include "mixin.h"', 'Mixin p;', 'Mixin n;'), + }); + expect(reached(cg, 'Mixin::Finish')).toEqual(['BaseTxn::Commit']); + expect(reached(cg, 'Mixin::Add')).toEqual([PUT_PLAIN]); + }); + + it('reads a member type of a parameter', async () => { + const cg = await indexed({ + 'all.cc': lines( + 'struct Key { static void Make() {} };', + 'struct Lock { static void Make() {} };', + 'struct Traits { using K = Key; struct Nested { static void Go() {} }; };', + 'template ', + 'struct User {', + ' using Mine = typename T::K;', + ' void Run() { Mine::Make(); T::Nested::Go(); }', + '};', + 'User user;', + ), + }); + expect(reached(cg, 'User::Run')).toEqual(['Key::Make', 'Traits::Nested::Go']); + }); + + it('reads a member enum of a parameter', async () => { + const cg = await indexed({ + 'item.h': lines('struct Item { enum Kind { One }; };', 'struct Odd { enum Kind { Two }; };'), + 'box.h': lines('#include "item.h"', 'template ', 'struct Box { void Run() { using K = typename T::Kind; K kind(0); } };'), + 'use.cc': lines('#include "box.h"', 'Box box;'), + }); + expect(reached(cg, 'Box::Run')).toEqual(['Item::Kind']); + }); + + it('constructs through a parameter', async () => { + const cg = await indexed({ + 'all.cc': lines( + 'struct Two { Two(int a, int b) {} };', + 'template ', + 'void Make() {', + ' T value(1, 2);', + ' T* made = new T(3, 4);', + '}', + 'void Run() { Make(); }', + ), + }); + const edges = templateEdges(cg); + expect(edges.filter((e) => e.kind === 'instantiates').map((e) => e.target)).toEqual(['Two']); + expect(edges.filter((e) => e.kind === 'calls').map((e) => e.target)).toEqual(['Two::Two']); + }); +}); + +describe('what is no instantiation', () => { + it('reads nothing from a comment, a string, a raw string or a macro body', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines( + '#include "mixin.h"', + '// Mixin in a line comment', + '/* Mixin in a block', + ' Mixin comment */', + 'const char* s = "Mixin";', + 'const char* r = R"cc(', + ' Mixin raw;', + ')cc";', + '#define USES Mixin', + '#define CONTINUED \\', + ' Mixin', + ), + }); + expect(templateEdges(cg)).toEqual([]); + }); + + it('reads no typed suite from a macro body', async () => { + const cg = await indexed({ + 'a_test.cc': lines( + '#include ', + 'struct A { static int Go() { return 1; } };', + 'struct B { static int Go() { return 2; } };', + 'template class T1 : public ::testing::Test {};', + '#define NEVER TYPED_TEST_SUITE(T1, ::testing::Types)', + 'TYPED_TEST_SUITE(T1, ::testing::Types);', + 'TYPED_TEST(T1, Works) { TypeParam::Go(); }', + ), + }); + expect(templateEdges(cg).map((e) => e.target)).toEqual(['A::Go']); + }); + + it('reads no longer name that ends in the template\'s', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines('#include "mixin.h"', 'template struct SuperMixin {};', 'SuperMixin other;', 'Mixin mine;'), + }); + expect(reached(cg, 'Mixin::Add')).toEqual([PUT_PLAIN]); + }); + + it('reads no friend, and no comparison', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines( + '#include "mixin.h"', + 'class Friendly { friend class Mixin; };', + 'int Mixin = 3;', + 'bool Less(int PlainTxn, int x) { return Mixin < PlainTxn && x > 1; }', + ), + }); + expect(templateEdges(cg)).toEqual([]); + }); + + it('keeps a namesake template out', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': lines( + '#include "txn.h"', + 'namespace mine {', + 'template ', + 'class Mixin { public: void Add(int k) { Txn::Put(k); } };', + '}', + ), + 'other.h': lines('namespace yours {', 'template class Mixin {};', '}'), + 'use.cc': lines( + '#include "mixin.h"', + '#include "other.h"', + 'yours::Mixin elsewhere;', + 'mine::Mixin here;', + ), + }); + expect(reached(cg, 'mine::Mixin::Add')).toEqual(['FancyTxn::Put']); + }); + + it('keeps a template of a source file to that file', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'a.cc': lines('#include "txn.h"', 'template struct Local { void Go() { T::Make(); } };', 'Local a;'), + 'b.cc': lines('#include "txn.h"', 'template struct Local { void Go() { T::Make(); } };', 'Local b;'), + }); + const edges = templateEdges(cg).map((e) => `${e.target} ${e.registeredAt}`).sort(); + expect(edges).toEqual(['FancyTxn::Make b.cc:3', 'PlainTxn::Make a.cc:3']); + }); + + it('keeps a call from a file that cannot see the function template out', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'run.h': lines('#include "txn.h"', 'template ', 'void Run() { T::Make(); }'), + 'use.cc': lines('#include "run.h"', 'void Use() { Run(); }'), + 'other.cc': lines('#include "txn.h"', 'void Elsewhere() { Run(); }'), + }); + expect(reached(cg, 'Run')).toEqual(['PlainTxn::Make']); + }); + + it('keeps a member template call out, whatever it shares a name with', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'get.h': lines('#include "txn.h"', 'template struct get { void Run() { T::Make(); } };'), + 'use.cc': lines('#include "get.h"', 'void Run(Holder& h, Holder* p) { h.get(); p->get(); }'), + }); + expect(templateEdges(cg)).toEqual([]); + }); + + it('reads a parameter in a head no node holds as a parameter, not as a class of its name', async () => { + const cg = await indexed({ + 'all.h': lines( + 'struct T { static void Go() {} };', + 'template struct Box { void Run() { U::Go(); } };', + 'template Box Make();', + 'template > struct Other {};', + ), + }); + expect(templateEdges(cg)).toEqual([]); + }); + + it('keeps two headers that declare one fixture name apart', async () => { + const test = (kind: string): string => lines( + 'template class Shared : public ::testing::Test {};', + 'TYPED_TEST_SUITE_P(Shared);', + `TYPED_TEST_P(Shared, ${kind}) { TypeParam::Go(); }`, + ); + const unit = (kind: string): string => lines( + `#include "${kind.toLowerCase()}_test.h"`, + `struct ${kind}Type { static int Go() { return 1; } };`, + `INSTANTIATE_TYPED_TEST_SUITE_P(${kind}, Shared, ::testing::Types<${kind}Type>);`, + ); + const cg = await indexed({ + 'map_test.h': test('Map'), + 'set_test.h': test('Set'), + 'map_test.cc': unit('Map'), + 'set_test.cc': unit('Set'), + }); + const db = (cg as unknown as { db: { db: { prepare(sql: string): { all(): Array<{ file: string; target: string }> } } } }).db.db; + expect(db.prepare( + `SELECT s.file_path file, t.qualified_name target FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.synthesizedBy') = 'cpp-template-argument' ORDER BY file` + ).all()).toEqual([{ file: 'map_test.h', target: 'MapType::Go' }, { file: 'set_test.h', target: 'SetType::Go' }]); + }); + + it('keeps a vendored copy of a class out of a file that does not include it', async () => { + const cg = await indexed({ + 'include/item.h': lines('namespace lib { struct Item { static void Go() {} }; }'), + 'tests/abi/item.h': lines('namespace lib { struct Item { static void Go() {} }; }'), + 'include/box.h': lines('#include "item.h"', 'namespace lib {', 'template ', 'struct Box { void Run() { T::Go(); } };', '}'), + 'tests/use.cc': lines('#include "../include/box.h"', 'lib::Box b;'), + }); + expect(templateEdges(cg).map((e) => e.registeredAt)).toEqual(['tests/use.cc:2']); + const db = (cg as unknown as { db: { db: { prepare(sql: string): { all(): Array<{ file_path: string }> } } } }).db.db; + expect(db.prepare(`SELECT n.file_path FROM edges e JOIN nodes n ON n.id = e.target WHERE json_extract(e.metadata, '$.synthesizedBy') = 'cpp-template-argument'`).all()) + .toEqual([{ file_path: 'include/item.h' }]); + }); +}); + +describe('a vendored copy under a plain namespace', () => { + it.each([ + ['at file scope', lines('using Mine = lib::Item;', 'Box b;')], + ['in a function', lines('void Use() {', ' using Mine = lib::Item;', ' Box b;', '}')], + ])('is not what an alias %s names when a macro hides the real one', async (_where, use) => { + const cg = await indexed({ + 'include/macros.h': lines('#define NS_BEGIN namespace lib {', '#define NS_END }'), + 'include/item.h': lines('#include "macros.h"', 'NS_BEGIN', 'struct Item { static void Go() {} };', 'NS_END'), + 'tests/abi/item.h': lines('namespace lib { struct Item { static void Go() {} }; }'), + 'include/box.h': lines('#include "item.h"', 'template ', 'struct Box { void Run() { T::Go(); } };'), + 'tests/use.cc': lines('#include "../include/box.h"', use), + }); + const db = (cg as unknown as { db: { db: { prepare(sql: string): { all(): Array<{ file_path: string }> } } } }).db.db; + expect(db.prepare(`SELECT n.file_path FROM edges e JOIN nodes n ON n.id = e.target WHERE json_extract(e.metadata, '$.synthesizedBy') = 'cpp-template-argument' AND n.file_path LIKE 'tests/%'`).all()) + .toEqual([]); + }); +}); + +describe('a member alias', () => { + it('names a class the file declaring it can see, not a vendored copy\'s', async () => { + const cg = await indexed({ + 'macros.h': lines('#define NS_BEGIN namespace lib {', '#define NS_END }'), + 'real/part.h': lines('#include "../macros.h"', 'NS_BEGIN', 'struct Part { static void Make() {} };', 'NS_END'), + 'vendored/part.h': lines('namespace lib { struct Part { static void Make() {} }; }'), + 'real/item.h': lines('#include "part.h"', 'struct Item { using P = ::lib::Part; };'), + 'other.h': lines('struct Lone { static void Make() {} };'), + 'box.h': lines('#include "real/item.h"', '#include "other.h"', 'template ', 'struct Box { void Run() { using P = typename T::P; P::Make(); } };'), + 'use.cc': lines('#include "box.h"', 'Box b;'), + }); + expect(templateEdges(cg).map((e) => e.target)).not.toContain('lib::Part::Make'); + }); +}); + +describe('a member alias of a vendored copy', () => { + it('is no member of the class the argument names', async () => { + const cg = await indexed({ + 'real/item.h': lines('struct Item {};'), + 'vendored/item.h': lines('struct Item { using Spare = int; };'), + 'box.h': lines('#include "real/item.h"', 'template ', 'struct Box { void Run() { using S = typename T::Spare; S spare(0); } };'), + 'use.cc': lines('#include "box.h"', 'Box b;'), + }); + expect(templateEdges(cg)).toEqual([]); + }); +}); + +describe('specializations', () => { + const HEADER = lines( + '#include "txn.h"', + 'template ', + 'struct Traits {', + ' void Primary() { T::Make(); }', + '};', + 'template <>', + 'struct Traits {', + ' void Full() {}', + '};', + 'template ', + 'struct Traits {', + ' void Partial() { U::Make(); }', + '};', + 'template ', + 'struct Traits {', + ' void Pointer() { T::Make(); }', + '};', + ); + + it('an instantiation a full specialization takes does not run the template', async () => { + const cg = await indexed({ + 'txn.h': lines(TXN, 'class ThirdTxn { public: static void Make() {} };'), + 'traits.h': HEADER, + 'use.cc': lines('#include "traits.h"', 'Traits full;', 'Traits plain;'), + }); + expect(reached(cg, 'Traits::Primary')).toEqual(['ThirdTxn::Make']); + }); + + it('a partial specialization reads its own parameter from what it matches', async () => { + const cg = await indexed({ + 'txn.h': lines(TXN, 'class ThirdTxn { public: static void Make() {} };'), + 'traits.h': HEADER, + 'use.cc': lines('#include "traits.h"', 'Traits a;', 'Traits b;'), + }); + expect(reached(cg, '::Partial')).toEqual(['PlainTxn::Make']); + expect(reached(cg, '::Pointer')).toEqual(['ThirdTxn::Make']); + expect(reached(cg, 'Traits::Primary')).toEqual([]); + }); + + it('an instantiation no specialization matches stays with the template', async () => { + const cg = await indexed({ + 'txn.h': lines(TXN, 'class ThirdTxn { public: static void Make() {} };'), + 'traits.h': HEADER, + 'use.cc': lines('#include "traits.h"', 'Traits a;'), + }); + expect(reached(cg, 'Traits::Primary')).toEqual(['ThirdTxn::Make']); + expect(reached(cg, '::Pointer')).toEqual([]); + }); + + it('each specialization keeps to the instantiations it takes', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'holder.h': lines( + '#include "txn.h"', + 'template struct Holder {};', + 'template struct Holder { void Pointer() { T::Make(); } };', + 'template struct Holder { void Ref() { T::Make(); } };', + ), + 'use.cc': lines('#include "holder.h"', 'Holder a;', 'Holder b;'), + }); + expect(reached(cg, '::Pointer')).toEqual(['PlainTxn::Make']); + expect(reached(cg, '::Ref')).toEqual(['FancyTxn::Make']); + }); + + it('reads a member of a partial specialization defined outside it', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'pair.h': lines( + '#include "txn.h"', + 'template ', + 'struct Pair { void Go(); };', + 'template ', + 'struct Pair { void Go(); };', + 'template ', + 'void Pair::Go() { T::Make(); }', + ), + 'use.cc': lines('#include "pair.h"', 'Pair a;', 'Pair b;'), + }); + expect(templateEdges(cg).map((e) => e.target)).toEqual(['PlainTxn::Make']); + }); + + it('a condition no one can read keeps an instantiation out of the template', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'traits.h': lines( + '#include "txn.h"', + 'template ', + 'struct Traits { void Primary() { T::Make(); } };', + 'template ', + 'struct Traits> { void Small() {} };', + ), + 'use.cc': lines('#include "traits.h"', 'Traits maybe;'), + }); + expect(reached(cg, 'Traits::Primary')).toEqual([]); + }); + + it('a member specialized out of line leaves the instantiation with the rest', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines('#include "mixin.h"', 'template <> void Mixin::Finish() {}', 'Mixin p;'), + }); + expect(reached(cg, 'Mixin::Add')).toEqual([PUT_PLAIN]); + }); +}); + +describe('the cap', () => { + const classes = (n: number): string => Array.from({ length: n }, (_, i) => `struct C${i} { static void Go() {} };`).join('\n'); + const uses = (n: number): string => Array.from({ length: n }, (_, i) => `Box b${i};`).join('\n'); + const box = lines('template ', 'struct Box { void Run() { T::Go(); } };'); + + it('links every class up to the cap', async () => { + const cg = await indexed({ 'all.cc': `${box}${classes(CPP_TEMPLATE_ARGUMENT_CAP)}\n${uses(CPP_TEMPLATE_ARGUMENT_CAP)}\n` }); + expect(reached(cg, 'Box::Run')).toHaveLength(CPP_TEMPLATE_ARGUMENT_CAP); + }); + + it('links none past it, not some', async () => { + const n = CPP_TEMPLATE_ARGUMENT_CAP + 1; + const cg = await indexed({ 'all.cc': `${box}${classes(n)}\n${uses(n)}\n` }); + expect(reached(cg, 'Box::Run')).toEqual([]); + }); + + it('counts classes, not sites', async () => { + const sites = Array.from({ length: CPP_TEMPLATE_ARGUMENT_CAP * 3 }, (_, i) => `Box s${i};`).join('\n'); + const cg = await indexed({ 'all.cc': `${box}${classes(1)}\n${sites}\n` }); + expect(reached(cg, 'Box::Run')).toEqual(['C0::Go']); + }); + + it('counts a chain with the classes it reaches', async () => { + const n = CPP_TEMPLATE_ARGUMENT_CAP + 1; + const wrapped = Array.from({ length: n }, (_, i) => `Wrap w${i};`).join('\n'); + const cg = await indexed({ 'all.cc': `${box}template struct Wrap : Box {};\n${classes(n)}\n${wrapped}\n` }); + expect(reached(cg, 'Box::Run')).toEqual([]); + }); +}); + +/** The template edges as text, for comparing two graphs. */ +const asText = (cg: CodeGraph): string[] => templateEdges(cg).map((e) => JSON.stringify(e)).sort(); + +describe('sync matches a fresh index', () => { + const BASE = { + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'keep.cc': lines('#include "mixin.h"', 'Mixin kept;'), + }; + + /** Index `before`, write `after`, sync, and compare with a fresh index of `after`. */ + const syncs = async (before: Record, after: Record, removed: string[] = []): Promise => { + const root = project(before); + const cg = await opened(root); + write(root, after); + for (const file of removed) fs.rmSync(path.join(root, file)); + await cg.sync(); + const fresh = await indexed(Object.fromEntries( + Object.entries({ ...before, ...after }).filter(([file]) => !removed.includes(file)))); + expect(asText(cg)).toEqual(asText(fresh)); + return reached(cg, 'Mixin::Add'); + }; + + it('an instantiation added in a third file', async () => { + expect(await syncs(BASE, { 'added.cc': lines('#include "mixin.h"', 'Mixin added;') })) + .toEqual(['FancyTxn::Put', PUT_PLAIN]); + }); + + it('an instantiation changed in a third file', async () => { + const before = { ...BASE, 'third.cc': lines('#include "mixin.h"', 'Mixin third;') }; + expect(await syncs(before, { 'third.cc': lines('#include "mixin.h"', 'Mixin third;') })).toEqual([PUT_PLAIN]); + }); + + it('an instantiation removed from a third file', async () => { + const before = { ...BASE, 'third.cc': lines('#include "mixin.h"', 'Mixin third;') }; + expect(await syncs(before, { 'third.cc': lines('#include "mixin.h"', 'int nothing;') })).toEqual([PUT_PLAIN]); + }); + + it('the file holding an instantiation deleted', async () => { + const before = { ...BASE, 'third.cc': lines('#include "mixin.h"', 'Mixin third;') }; + expect(await syncs(before, {}, ['third.cc'])).toEqual([PUT_PLAIN]); + }); + + it('a file holding nothing but an instantiation', async () => { + const before = { + ...BASE, + 'bare.hpp': 'int nothing;\n', + 'unit.cc': lines('#include "mixin.h"', '#include "bare.hpp"'), + }; + expect(await syncs(before, { 'bare.hpp': 'Mixin bare;\n' })).toEqual(['FancyTxn::Put', PUT_PLAIN]); + expect(hasSynthesisPattern('bare.hpp', 'Mixin bare;\n')).toBe(true); + }); + + it('a member added to, and removed from, an argument', async () => { + const before = { ...BASE, 'third.cc': lines('#include "mixin.h"', 'Mixin third;') }; + const withPut = TXN.replace('class NoPutTxn {};', 'class NoPutTxn { public: void Put(int k) {} };'); + expect(await syncs(before, { 'txn.h': withPut })).toEqual(['NoPutTxn::Put', PUT_PLAIN]); + }); + + it('a template edited to call another member', async () => { + const after = { 'mixin.h': MIXIN.replace('Txn::Commit();', 'Txn::Commit(); Txn::Make();') }; + await syncs(BASE, after); + }); + + it('a typed list edited', async () => { + const test = (list: string): string => lines( + '#include ', + 'struct A { static int Go() { return 1; } };', + 'struct B { static int Go() { return 2; } };', + 'template class T1 : public ::testing::Test {};', + 'TYPED_TEST_SUITE(T1, ::testing::Types);', + 'TYPED_TEST(T1, Works) { TypeParam::Go(); }', + ).replace('Types', `Types<${list}>`); + const root = project({ 'a_test.cc': test('A') }); + const cg = await opened(root); + write(root, { 'a_test.cc': test('A, B') }); + await cg.sync(); + const fresh = await indexed({ 'a_test.cc': test('A, B') }); + expect(asText(cg)).toEqual(asText(fresh)); + expect(templateEdges(cg).map((e) => e.target).sort()).toEqual(['A::Go', 'B::Go']); + }); +}); + +describe('determinism', () => { + const FILES = { + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'b.cc': lines('#include "mixin.h"', 'Mixin b;', 'Mixin again;'), + 'a.cc': lines('#include "mixin.h"', 'Mixin a;'), + }; + + it('gives the same edges, in the same order, with the resolver pool and without', async () => { + const before = process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN; + try { + process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN = '999999999'; + const sequential = (await indexed(FILES)); + process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN = '0'; + const pooled = (await indexed(FILES)); + expect(templateEdges(pooled)).toEqual(templateEdges(sequential)); + expect(templateEdges(sequential).length).toBeGreaterThan(0); + } finally { + if (before === undefined) delete process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN; + else process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN = before; + } + }); + + it('names the first site by file and line, whichever a run reads first', async () => { + const cg = await indexed(FILES); + expect(templateEdges(cg).find((e) => e.target === PUT_PLAIN)?.registeredAt).toBe('a.cc:2'); + }); +}); + +describe('how a hop reads', () => { + it('says which argument, for which parameter, and where', () => { + const edge = { + source: 'a', target: 'b', kind: 'calls', provenance: 'heuristic', + metadata: { synthesizedBy: 'cpp-template-argument', via: 'Txn', argument: 'PlainTxn', registeredAt: 'use.cc:2' }, + } as unknown as Edge; + expect(describeSynthesizedHop(edge)).toEqual({ summary: 'template argument `PlainTxn` for `Txn`', detail: '', registeredAt: 'use.cc:2' }); + }); + + it('connects a flow through the template in explore', async () => { + const cg = await indexed({ + 'txn.h': TXN, + 'mixin.h': MIXIN, + 'use.cc': lines('#include "mixin.h"', 'void Start() { Mixin m; m.Add(1); }'), + }); + const { ToolHandler } = await import('../src/mcp/tools'); + const handler = new ToolHandler(cg); + const result = await handler.execute('codegraph_explore', { query: 'Start Mixin::Add PlainTxn::Put' }); + const text = result.content.map((c) => ('text' in c ? c.text : '')).join('\n'); + expect(text).toContain('template argument `PlainTxn` for `Txn`'); + expect(text).toContain('use.cc:2'); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 87199338f..f62ea6cd8 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4454,6 +4454,36 @@ export class QueryBuilder { })); } + /** + * The failed C++ calls and function values with a qualifier outside `std` + * (`Txn::Put`, `T::T/2`), calls on a variable (`shard.Clear`), and what + * values are constructed as, which may go through a template parameter + * (cpp-template-instantiations.ts), one at a time: there can be a great + * many, few of which do. + */ + *iterateFailedCppTypeReferences(): IterableIterator { + const rows = this.db.prepare( + `SELECT from_node_id, reference_name, reference_kind, line, col, file_path FROM unresolved_refs + WHERE status = 'failed' AND language = 'cpp' AND (reference_kind = 'instantiates' + OR (reference_kind IN ('calls', 'function_ref') AND reference_name GLOB '*::*' AND reference_name NOT GLOB 'std::*') + OR (reference_kind = 'calls' AND reference_name GLOB '*.*' AND reference_name NOT GLOB '*(*'))` + ).iterate() as IterableIterator>; + for (const row of rows) { + yield { + fromNodeId: row.from_node_id, + referenceName: row.reference_name, + referenceKind: row.reference_kind as EdgeKind, + line: row.line, + column: row.col, + filePath: row.file_path, + language: 'cpp', + }; + } + // The walk reads the whole table, which would stay in this connection's page + // cache through the end of synthesis, where an index peaks in memory. + this.db.exec('PRAGMA shrink_memory'); + } + /** * Get the count of PENDING (never-attempted) references without loading * them into memory. Rows marked status='failed' — attempted by a completed diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts index 39b7fece4..7f5673ae1 100644 --- a/src/graph/synthesized-hop.ts +++ b/src/graph/synthesized-hop.ts @@ -85,6 +85,9 @@ export function describeSynthesizedHop(edge: Edge | null | undefined): Synthesiz return hop(m.matcher ? `Next proxy, matcher ${quoted(m.matcher, '')}` : 'Next proxy, on every request'); case 'const-alias': return hop(`const alias of ${quoted(m.via, 'a function')}`); + case 'cpp-template-argument': + // C++: a call through a template parameter; the wiring site writes the argument. + return hop(`template argument ${quoted(m.argument, 'a class')} for ${quoted(m.via, 'a parameter')}`); case 'go-func-field': // Go: a call through a struct field holding a function, `d.refreshf(ctx)`; // `via` is the field it was copied from, which the wiring site writes. diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index b69341030..920217222 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection'; import { stripCommentsForRegex } from './strip-comments'; import { escapeRegExp } from '../utils'; import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; +import { cppTemplateArgumentEdges } from './cpp-template-instantiations'; import { goframeRouteEdges } from './goframe-synthesizer'; import { goFuncFieldEdges } from './go-func-field-synthesizer'; import { expoRouterReturnEdges } from './expo-router-synthesizer'; @@ -5313,7 +5314,7 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean // A Next proxy and the routes its matcher covers are joined by path and config, not by any line of code. if (NEXT_PROXY_FILE.test(filePath) || nextRouteForFile(filePath) !== null) return true; if (/\.(?:c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc|def|inc|tbl)$/i.test(filePath) && - /\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true; + /\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[| has('c', 'cpp'), run: (q, c, y, sub) => cFnPointerDispatchEdges(q, c, y, sub), }, + // `Txn::Put(…)` in `template class Mixin` onto each class the project writes as `Txn`. + // After the other C++ passes: a pair one of them bridges keeps its edge. + { name: 'cppTemplateEdges', gate: (has) => has('cpp'), run: (q, c, y) => cppTemplateArgumentEdges(q, c, y) }, { name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) }, // `d.refreshf(ctx)` onto the functions put in that func field (`RefreshF: d.refresh`). // After the Go framework passes: a pair one of them bridges keeps its more specific edge. diff --git a/src/resolution/cpp-supertypes.ts b/src/resolution/cpp-supertypes.ts index ca30a7d7e..a79a9f17c 100644 --- a/src/resolution/cpp-supertypes.ts +++ b/src/resolution/cpp-supertypes.ts @@ -107,7 +107,7 @@ export function isCppDeclaredTypeRef(ref: UnresolvedRef, context: ResolutionCont function seenFrom(site: CppNameSite, context: ResolutionContext, unit?: string): (n: Node) => boolean { const visible = visibleFrom(site.file, unit); return (n) => { - if (!visible(n)) return false; + if (!visible(n) || site.sees?.(n) === false) return false; const fn = cppFunctionAround(n, context); return !fn || (fn.filePath === site.file && site.line >= fn.startLine && site.line <= fn.endLine); }; @@ -230,6 +230,8 @@ export interface CppNameSite { * before the enclosing namespaces'. */ classScope?: string; + /** Which declarations the file writing the name can see, when that is narrower than a header's being anyone's. */ + sees?: (n: Node) => boolean; } /** The class a C or C++ type name names. */ diff --git a/src/resolution/cpp-template-instantiations.ts b/src/resolution/cpp-template-instantiations.ts new file mode 100644 index 000000000..959788092 --- /dev/null +++ b/src/resolution/cpp-template-instantiations.ts @@ -0,0 +1,1133 @@ +/** + * A C++ template parameter reaches the classes the template is instantiated + * with. + * + * `Txn::PutEntity(…)` in rocksdb's `template class + * SecondaryIndexMixin` is a member of whatever class the template is given, so + * no lookup at the call can answer it. A guess by name is no answer: the rule + * that took `WriteCommittedTxn::PutEntity` because the class's name ends in + * `Txn` sent other calls to the wrong class, and every such call now fails + * (gateTypeParameter). What decides the target is written elsewhere, in + * `new SecondaryIndexMixin(…)`, and with several + * instantiations there is no one target: each really runs its own. So this is + * a whole-graph pass over the references that failed, not a resolver: one + * reference gets one edge per class the project writes as the argument. + * + * What it reads, from each failed reference that goes through a parameter: + * - The template that declares the parameter, and which parameter it is: the + * function's own head, the class templates around it, or, defined outside + * its class, the head that matches `C::` (cppTemplateHeads). A gtest + * `TYPED_TEST(Fixture, Name)` body's `TypeParam` is the fixture's first. + * - An alias of a member of a parameter counts as that member: + * `using KeyGen = typename TypeParam::KeyGen;`. + * - A variable declared with such a type counts too: `shard.Clear()` on a + * `CacheShard& shard`. + * - A class template's instantiations are wherever `Name<…>` is written and + * `Name` is that class by C++ lookup: a base clause, a variable, member or + * `new`, an alias, `template class Name;`, gtest's `::testing::Types` + * through `TYPED_TEST_SUITE`. A function template's are the calls the graph + * already holds, read for the `f(…)` they write, or for the type of + * the argument that decides what `f(x)` deduces. + * - An argument that is itself a parameter of a template around the site is + * followed to that template's instantiations, a few hops at most. + * - An argument is a project class by the lookup a declared type gets + * (cppClassWritten), a class local to the function that writes it first, + * in a file that sees it. Anything else (`std::string`, `int`, a pointer) + * gives no edge, and takes nothing from the other instantiations. + * - The member must be that class's own or inherited (cppMethodOf); a + * constructor is chosen as a declared object's is (matchCppConstructor). + * - An instantiation a specialization takes runs the specialization's + * members, not the members of the template it specializes. + * + * The argument's site is the edge's `registeredAt`: a file holding only an + * instantiation is what the edge depends on, so a sync that touches it runs + * this pass again. Nothing here looks at an edge a resolver made, and a + * reference with no answer stays failed. + */ +import type { Edge, EdgeKind, Node } from '../types'; +import type { QueryBuilder } from '../db/queries'; +import type { MaybeYield } from './cooperative-yield'; +import type { ResolutionContext, UnresolvedRef } from './types'; +import { matchCppConstructor } from './cpp-constructor'; +import { cppIncluders } from './cpp-includers'; +import { cppMacroNamespaceFrames } from './cpp-namespaces'; +import { cppLiteralsBlanked, cppNonCodeLines } from './cpp-raw-strings'; +import { cppClassWritten, type CppNameSite } from './cpp-supertypes'; +import { + cppAliasWrittenAt, + cppAliasedClass, + cppClassNamed, + cppDeclaresLocalAlias, + cppParentScope, + cppTemplateHeads, + cppTemplateParametersIn, + cppTypeNamed, + cppTypeNamesDeclared, + cppTypeSegments, + isCppPointerType, + resolveCppAliasedType, + stripCppTemplateArguments, + type CppTemplateParameter, +} from './cpp-type-aliases'; +import { + cppArgumentType, + cppCallArguments, + cppCallTemplateArguments, + cppCallersMember, + cppMethodOf, + cppOverloadParameters, + isCppLocalName, + splitCppTopLevel, +} from './name-matcher'; +import { LRUCache } from './lru-cache'; +import { stripCommentsForRegex } from './strip-comments'; +import { makeLineAt } from './synth-utils'; + +/** + * How many classes one parameter may reach before it reaches none. A template + * written with more than this is a container or a test helper, whose every + * instantiation is no hop a flow takes (see AGENTS.md on a god-function's + * fan-out). A template over the cap gets no edges, not some. + */ +export const CPP_TEMPLATE_ARGUMENT_CAP = 40; + +/** How many templates an argument is followed through, from a parameter to the parameter it is written as. */ +const MAX_TEMPLATE_HOPS = 4; +/** How far an argument list may run: a gtest list of types takes a line per type. */ +const MAX_ARGUMENT_LIST = 64 * 1024; +/** How much text before a `Name<` tells what is written there: a template head, at most. */ +const HEAD_WINDOW = 400; +/** How many files' text and declarations are kept: every file of most projects, and a bound on the rest. */ +const MAX_FILES_HELD = 4096; +/** How many templates' instantiations are kept. */ +const MAX_TEMPLATES_HELD = 1024; + +const CLASS_KINDS: ReadonlySet = new Set(['class', 'struct', 'union']); +const FUNCTION_KINDS: ReadonlySet = new Set(['function', 'method']); +/** What a template argument is written in, and so what a site can sit in. */ +const DECLARATION_KINDS: ReadonlySet = new Set([...CLASS_KINDS, ...FUNCTION_KINDS, 'type_alias', 'variable', 'constant']); + +const CPP_FILE = /\.(?:c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc)$/i; +const IDENTIFIER = /^[A-Za-z_]\w*$/; +/** A call on a variable, as a reference spells it: `shard.Clear`, for `shard->Clear()` too. */ +const VALUE_CALL = /^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/; +/** What a declaration may write around its type and between it and the name. */ +const CV_QUALIFIER = /\b(?:const|volatile|typename|mutable|static)\b/g; +/** A declaration of a value: the type's first name, then the variable's: `Shard& shard`, `Shard::Part* part`, `T* NONNULL p`. */ +const DECLARED_VALUE = /\b([A-Za-z_]\w*)(?:\s*::\s*[A-Za-z_]\w*)*(?:\s*<[^<>;{}()]*>)?[\s*&]+(?:[A-Z][A-Z0-9_]*\s+)*(?=([A-Za-z_]\w*)\b)/g; + +/** gtest's macros that give a typed fixture its list of types, with the fixture. */ +const TYPED_SUITE = /\b(?:TYPED_TEST_(?:SUITE|CASE)\s*\(|INSTANTIATE_TYPED_TEST_(?:SUITE|CASE)_P\s*\(\s*[A-Za-z_]\w*\s*,)\s*((?:::\s*)?[A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*)\s*,/g; +/** How the index names a class it reads no name for, such as a specialization. */ +const ANONYMOUS = ''; +/** A class declaration that writes template arguments after its name, up to the `<`. */ +const SPECIALIZATION_HEAD = /^(?:class|struct|union)\s+(?:[A-Z][A-Z0-9_]*(?:\([^()]*\))?\s+)*((?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*)\s*`. */ +const CONDITION = /\b(?:enable_if|void_t|disjunction|conjunction|negation|conditional|requires)\w*|\bis_\w+/; + +/** A template's head, as the declaration it belongs to writes it. */ +interface Template { + /** One per template, whichever declaration of it a reference is in. */ + key: string; + kind: 'class' | 'alias' | 'function'; + name: string; + /** The declaration of a class or alias template; the very function for a function template. */ + node: Node; + parameters: readonly CppTemplateParameter[]; + /** What a specialization writes after the name (`T*`, `int`); null for the template itself. */ + pattern: readonly string[] | null; +} + +/** A parameter of a template. */ +interface Slot { + template: Template; + index: number; +} + +/** A reference that goes through a parameter. */ +interface Through { + slot: Slot; + /** The parameter as the code around the reference names it. */ + parameter: string; + /** Member types between the parameter and what is named: `Key` of `T::Key::Make`, or of an alias of `T::Key`. */ + types: readonly string[]; + /** The member called: `Make`; null for the type itself. */ + member: string | null; + /** The argument count of a constructor call: `T x(a, b);`. */ + arity: number | null; +} + +/** Where a type name is written: what it is looked up from. */ +interface Place { + file: string; + line: number; + scope: string; + /** The declaration around it. */ + around: Node | null; + /** The templates around it, innermost first. */ + templates: readonly Template[]; + /** The names that are parameters there, those of a head no node holds as well. */ + parameters: ReadonlySet; + ref: UnresolvedRef; +} + +/** A template argument as written. */ +interface Argument { + text: string; + place: Place; +} + +/** A written `Name<…>` that names a template. */ +interface Instantiation { + args: readonly Argument[]; +} + +/** A call of a function template. */ +interface Call { + ref: UnresolvedRef; + place: Place; + /** The template arguments it writes; null for none, leaving them to be deduced. */ + args: readonly Argument[] | null; +} + +/** One argument a specialization writes: `T`, `T*`, `int`, or a condition on `T`. */ +interface PatternItem { + text: string; + /** The parameter it is, whole. */ + parameter?: string; + pack?: boolean; + /** What only the arguments decide. */ + condition?: boolean; + /** How it reads a parameter out of an argument, with the parameters it does. */ + shape?: RegExp; + words: readonly string[]; +} + +/** A specialization of a class template: `template class Name`. */ +interface Specialization { + pattern: readonly PatternItem[]; + /** The arguments as they compare, to find a declaration's own. */ + written: string; +} + +/** What a class template's name is written as across the project. */ +interface WrittenTemplate { + instantiations: Instantiation[]; + specializations: Specialization[]; +} + +/** A file's text with comments and strings blanked, offsets kept. */ +interface Source { + text: string; + lineAt: (offset: number) => number; +} + +/** The arguments between the `<` at `open` and the `>` at `close`, split at the top level, each with where it starts. */ +function argumentsBetween(text: string, open: number, close: number): Array<{ text: string; offset: number }> { + const out: Array<{ text: string; offset: number }> = []; + let depth = 0; + let parens = 0; + let start = open + 1; + const push = (end: number): void => { + const raw = text.slice(start, end); + const trimmed = raw.trim(); + if (trimmed) out.push({ text: trimmed, offset: start + raw.indexOf(trimmed) }); + }; + for (let i = open + 1; i < close; i++) { + const c = text[i]!; + if (c === '(' || c === '[') parens++; + else if (c === ')' || c === ']') parens--; + else if (parens > 0) continue; + else if (c === '<') depth++; + else if (c === '>' && text[i - 1] !== '-') depth--; + else if (c === ',' && depth === 0) { + push(i); + start = i + 1; + } + } + push(close); + return out; +} + +/** The `>` closing the argument list opened at `open`, or -1 when `<` opens none there. */ +function closingAngle(text: string, open: number): number { + let depth = 0; + let parens = 0; + const end = Math.min(text.length, open + MAX_ARGUMENT_LIST); + for (let i = open; i < end; i++) { + const c = text[i]!; + if (c === '(' || c === '[') parens++; + else if (c === ')' || c === ']') { + if (--parens < 0) return -1; + } else if (parens > 0) continue; + else if (c === '<') depth++; + else if (c === '>') { + if (text[i - 1] === '-') continue; + if (--depth === 0) return i; + } else if (c === ';' || c === '{' || c === '}') return -1; + } + return -1; +} + +const isWord = (code: number): boolean => + (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95; +const isSpace = (code: number): boolean => code === 32 || code === 9 || code === 10 || code === 13; + +/** A copy of a name read from a file's text. V8 keeps a whole string alive for a long enough part of it. */ +const detached = (name: string): string => `_${name}`.slice(1); + +/** Add the names `text` writes right before a `<`. */ +function addNamesBeforeAngle(text: string, names: Set): void { + for (let open = text.indexOf('<'); open >= 0; open = text.indexOf('<', open + 1)) { + let end = open; + while (end > 0 && isSpace(text.charCodeAt(end - 1))) end--; + let start = end; + while (start > 0 && isWord(text.charCodeAt(start - 1))) start--; + if (start < end) names.add(text.slice(start, end)); + } +} + +/** The qualifiers `A::B::` written right before `end`, outermost first, each with the arguments it writes. */ +function qualifiersBefore(text: string, end: number): Array<{ name: string; args: string[] | null }> { + const out: Array<{ name: string; args: string[] | null }> = []; + let at = end; + for (;;) { + while (at > 0 && isSpace(text.charCodeAt(at - 1))) at--; + if (text.slice(at - 2, at) !== '::') return out; + at -= 2; + while (at > 0 && isSpace(text.charCodeAt(at - 1))) at--; + let args: string[] | null = null; + if (text[at - 1] === '>') { + let depth = 0; + let i = at - 1; + for (; i >= 0; i--) { + if (text[i] === '>') depth++; + else if (text[i] === '<' && --depth === 0) break; + } + if (i < 0) return out; + args = argumentsBetween(text, i, at - 1).map((a) => a.text); + at = i; + while (at > 0 && isSpace(text.charCodeAt(at - 1))) at--; + } + let start = at; + while (start > 0 && isWord(text.charCodeAt(start - 1))) start--; + if (start === at) return out; + out.unshift({ name: text.slice(start, at), args }); + at = start; + } +} + +const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** A written template argument as it compares: spaced only between words. */ +function argumentKey(arg: string): string { + return arg.replace(/\s*([^\w\s])\s*/g, '$1').replace(/\s+/g, ' '); +} + +const slotKey = (slot: Slot): string => `${slot.template.key}#${slot.index}#${slot.template.pattern?.join() ?? ''}`; + +/** Do the arguments a declaration writes for its template repeat its head: `C` under `template `? */ +function isOwnHead(args: readonly string[], head: readonly CppTemplateParameter[]): boolean { + return args.length === head.length && args.every((a, k) => a.replace(/\s*\.\.\.$/, '') === head[k]!.name); +} + +/** A specialization, from the arguments it writes (as they compare) and its head. */ +function specializationOf(args: readonly string[], parameters: readonly CppTemplateParameter[]): Specialization { + const names = new Set(parameters.flatMap((p) => (p.name ? [p.name] : []))); + const pattern = args.map((text): PatternItem => { + const bare = text.replace(/\.\.\.$/, ''); + if (names.has(bare)) return { text, parameter: bare, pack: bare !== text, words: [] }; + const words = (text.match(/[A-Za-z_]\w*/g) ?? []).filter((w) => names.has(w)); + if (words.length === 0) return { text, words }; + if (CONDITION.test(text)) return { text, condition: true, words }; + return { text, words, shape: new RegExp(`^${text.split(/([A-Za-z_]\w*)/).map((part) => (names.has(part) ? '(.+)' : escapeRegExp(part))).join('')}$`) }; + }); + return { pattern, written: args.join() }; +} + +/** The `namespace a { … }` blocks of a blanked file, by offset, outermost first. */ +function namespaceBlocks(text: string): Array<{ start: number; end: number; path: readonly string[] }> { + const blocks: Array<{ start: number; end: number; path: readonly string[] }> = []; + const open: Array<{ start: number; end: number; path: readonly string[] } | null> = []; + let path: readonly string[] = []; + for (const m of text.matchAll(/\bnamespace\b(?:\s+([A-Za-z_][\w:\s]*?))?\s*\{|[{}]/g)) { + if (m[0] === '{') open.push(null); + else if (m[0] === '}') { + const block = open.pop(); + if (block) { + block.end = m.index!; + path = open.reduce((p, b) => b?.path ?? p, []); + } + } else { + const names = m[1]?.replace(/\s+/g, '') ?? ''; + const block = { start: m.index!, end: text.length, path: names ? [...path, ...names.split('::')] : path }; + blocks.push(block); + open.push(block); + path = block.path; + } + } + return blocks; +} + +class TemplateInstantiations { + private readonly sources = new LRUCache(MAX_FILES_HELD); + private readonly declarations = new LRUCache(MAX_FILES_HELD); + private readonly namespaces = new LRUCache>(MAX_FILES_HELD); + private readonly templates = new Map(); + private readonly written = new LRUCache(MAX_TEMPLATES_HELD); + private readonly calls = new Map(); + private readonly seen = new Map(); + private readonly values = new Map>(); + /** The C and C++ files, each with the names it writes before a `<`, read once. */ + private readonly filesNaming = new Map(); + /** The files that use gtest's typed tests. */ + private readonly typedFiles: string[] = []; + private suites: Map> | null = null; + private scanned = 0; + + constructor( + private readonly queries: QueryBuilder, + private readonly ctx: ResolutionContext, + private readonly onYield: MaybeYield, + ) {} + + /** + * Read every C and C++ file once, for the names each writes before a `<`, + * and return the names each that uses templates declares as a parameter or + * an alias. + */ + async read(): Promise>> { + const declared = new Map>(); + for (const file of [...this.ctx.getAllFiles()].sort()) { + if (!CPP_FILE.test(file)) continue; + if ((++this.scanned & 63) === 0) await this.onYield(); + const text = this.ctx.scanFile?.(file) ?? this.ctx.readFile(file); + if (!text) continue; + const names = new Set(); + addNamesBeforeAngle(text, names); + for (const name of names) { + const list = this.filesNaming.get(name); + if (list) list.push(file); + else this.filesNaming.set(detached(name), [file]); + } + const typed = text.includes('TYPED_TEST'); + if (typed) this.typedFiles.push(file); + if (!typed && !text.includes('template')) continue; + const types = new Set([...cppTypeNamesDeclared(text)].map(detached)); + if (typed) types.add('TypeParam'); + declared.set(file, types); + } + return declared; + } + + private lines(file: string): readonly string[] { + return this.ctx.getFileLines?.(file) ?? this.ctx.readFile(file)?.split(/\r?\n/) ?? []; + } + + /** The classes of a qualified name, in file and line order: the walks above take the first that fits. */ + private classesNamed(qualified: string): Node[] { + return this.ctx.getNodesByQualifiedName(qualified).filter((n) => CLASS_KINDS.has(n.kind)) + .sort((a, b) => (a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0) || a.startLine - b.startLine); + } + + /** The declaration of a class or function around `node`, whose lines hold it, in the scope its qualified name gives. */ + private declarationAround(node: Node): Node | null { + for (let scope = cppParentScope(node.qualifiedName); scope; scope = cppParentScope(scope)) { + const around = this.ctx.getNodesByQualifiedName(scope).find((n) => + n.id !== node.id && (CLASS_KINDS.has(n.kind) || FUNCTION_KINDS.has(n.kind)) && n.filePath === node.filePath && + n.startLine <= node.startLine && (n.endLine ?? n.startLine) >= (node.endLine ?? node.startLine)); + if (around) return around; + } + return null; + } + + /** + * The class template a class declaration's head belongs to. A specialization + * has no name of its own in the index (``), and a nested class + * defined outside its template names another class, so that is read from + * what the declaration writes, `struct Traits`, and found as any + * name is. Null when no class of that name is found. + */ + private templateOfClass(decl: Node, head: readonly CppTemplateParameter[]): Template | null { + const text = this.lines(decl.filePath).slice(decl.startLine - 1, decl.startLine + 7).join('\n').slice(decl.startColumn); + const written = SPECIALIZATION_HEAD.exec(text); + const close = written ? closingAngle(text, written[0].length - 1) : -1; + const args = close < 0 ? null : argumentsBetween(text, written![0].length - 1, close).map((a) => a.text); + const pattern = args && !isOwnHead(args, head) ? args : null; + // `template struct List::Node {`: its head is `List`'s. + const nested = close >= 0 && /^\s*::/.test(text.slice(close + 1)); + if (decl.name !== ANONYMOUS && !nested) return this.classTemplate(decl, head, pattern); + if (!args) return null; + const place = this.placeIn(decl, decl.startLine); + const found = cppClassWritten(written![1]!.replace(/\s+/g, ''), { ...this.site(place), scope: cppParentScope(decl.qualifiedName) }, place.ref, this.ctx); + return found?.exact && CLASS_KINDS.has(found.node.kind) ? this.classTemplate(found.node, head, pattern) : null; + } + + private classTemplate(decl: Node, parameters: readonly CppTemplateParameter[], pattern: readonly string[] | null): Template { + return { key: `class:${decl.qualifiedName}@${decl.filePath}`, kind: 'class', name: decl.name, node: decl, parameters, pattern }; + } + + private functionTemplate(node: Node, parameters: readonly CppTemplateParameter[]): Template { + return { key: `function:${node.id}`, kind: 'function', name: node.name, node, parameters, pattern: null }; + } + + /** + * The class templates a definition outside its class writes in its + * declarator (`template void C::f()`), innermost first, after + * a member template's own head. + */ + private outOfLineTemplates(node: Node, heads: ReadonlyArray): Template[] { + const text = this.lines(node.filePath).slice(node.startLine - 1, node.startLine + 11).join('\n').slice(node.startColumn); + const name = new RegExp(String.raw`::\s*(?:template\s+)?~?${escapeRegExp(node.name)}\s*[(<]`).exec(text); + const written = name ? qualifiersBefore(text, name.index + 2).filter((q) => q.args) : []; + const out: Template[] = []; + if (heads.length !== written.length && heads.length !== written.length + 1) return out; + if (heads.length === written.length + 1) out.push(this.functionTemplate(node, heads[heads.length - 1]!)); + const segments = node.qualifiedName.split('::'); + for (let i = written.length - 1; i >= 0; i--) { + const q = written[i]!; + const head = heads[i]!; + const at = segments.lastIndexOf(q.name, segments.length - 2); + const decls = at < 0 ? [] : this.classesNamed(segments.slice(0, at + 1).join('::')); + const decl = decls.find((n) => n.filePath === node.filePath) ?? decls.find((n) => this.sees(node.filePath, n)); + if (!decl || head.length === 0) continue; + out.push(this.classTemplate(decl, head, isOwnHead(q.args!, head) ? null : q.args)); + } + return out; + } + + /** The typed fixture a gtest `TYPED_TEST` body is a member of, with `TypeParam` as its first parameter. */ + private typedTestTemplate(caller: Node): Template | null { + if (caller.kind !== 'function' || !/^TYPED_TEST(?:_P)?$/.test(caller.name)) return null; + const head = this.lines(caller.filePath).slice(caller.startLine - 1, caller.startLine + 1).join(' '); + const fixture = /\bTYPED_TEST(?:_P)?\s*\(\s*((?:::\s*)?[A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*)\s*,/.exec(head)?.[1]; + if (!fixture) return null; + const place = this.placeIn(caller, caller.startLine); + const found = cppClassWritten(fixture.replace(/\s+/g, ''), this.site(place), place.ref, this.ctx); + if (!found?.exact || !CLASS_KINDS.has(found.node.kind)) return null; + return this.classTemplate(found.node, [{ name: 'TypeParam', pack: false, fallback: null }], null); + } + + /** The templates around a declaration, innermost first, each with the parameters its body sees. */ + templatesAround(node: Node): readonly Template[] { + const hit = this.templates.get(node.id); + if (hit) return hit; + const out: Template[] = []; + this.templates.set(node.id, out); + const heads = cppTemplateHeads(node, this.ctx); + const around = this.declarationAround(node); + if (heads.length > 0) { + const own = heads[heads.length - 1]!; + if (CLASS_KINDS.has(node.kind)) { + const template = this.templateOfClass(node, own); + if (template) out.push(template); + } else if (node.kind === 'type_alias') { + out.push({ key: `alias:${node.qualifiedName}`, kind: 'alias', name: node.name, node, parameters: own, pattern: null }); + } else if (FUNCTION_KINDS.has(node.kind)) { + if (around || !cppParentScope(node.qualifiedName)) out.push(this.functionTemplate(node, own)); + else out.push(...this.outOfLineTemplates(node, heads)); + } + } + const typed = this.typedTestTemplate(node); + if (typed) out.push(typed); + if (around) out.push(...this.templatesAround(around)); + return out; + } + + private slotNamed(name: string, templates: readonly Template[]): Slot | null { + for (const template of templates) { + const index = template.parameters.findIndex((p) => p.name === name); + if (index >= 0) return { template, index }; + } + return null; + } + + /** + * The parameter a type written as `segments` starts with where `ref` is + * made, with the member types after it: itself, or through an alias + * (`using KeyGen = typename TypeParam::KeyGen;`). + */ + private parameterOf(segments: readonly string[], ref: UnresolvedRef, caller: Node): { slot: Slot; parameter: string; types: string[] } | null { + const head = segments[0]!; + const direct = this.slotNamed(head, this.templatesAround(caller)); + if (direct) return { slot: direct, parameter: head, types: segments.slice(1) }; + const alias = cppAliasWrittenAt(head, ref, this.ctx); + const written = alias && cppTypeSegments(alias.text); + if (!written) return null; + const slot = this.slotNamed(written[0]!, this.templatesAround(alias.alias ?? caller)); + return slot ? { slot, parameter: written[0]!, types: [...written.slice(1), ...segments.slice(1)] } : null; + } + + /** + * The names a function's lines declare with a type that starts with a name + * of `types`, a parameter or an alias: all a call on a variable of the + * function's own can go through. Typing every variable a template calls + * something on would be most of what this pass costs. + */ + private localValues(caller: Node, types: ReadonlySet): ReadonlySet { + const hit = this.values.get(caller.id); + if (hit) return hit; + const values = new Set(); + const text = this.lines(caller.filePath).slice(caller.startLine - 1, caller.endLine ?? caller.startLine).join('\n').replace(CV_QUALIFIER, ' '); + for (const m of text.matchAll(DECLARED_VALUE)) if (types.has(m[1]!)) values.add(m[2]!); + this.values.set(caller.id, values); + return values; + } + + /** A call on a variable whose declared type is a parameter: `shard.Clear()` on a `CacheShard& shard`, `p->Clear()` on a `T* p`. */ + private throughValue(ref: UnresolvedRef, caller: Node, receiver: string, member: string, types: ReadonlySet): Through | null { + if (this.templatesAround(caller).length === 0) return null; + let text: string | undefined; + if (this.localValues(caller, types).has(receiver)) { + const declared = cppArgumentType(receiver, ref, this.ctx); + text = declared?.kind === 'type' ? declared.text : undefined; + } else { + // (Typing an argument leaves out a member of a template parameter's type.) + const field = cppCallersMember(receiver, caller, ref, this.ctx)?.text; + const head = field ? cppTypeSegments(field)?.[0] : undefined; + if (head && types.has(head) && !isCppLocalName(receiver, caller, ref, this.ctx)) text = field; + } + const written = text ? cppTypeSegments(text) : null; + const type = written && this.parameterOf(written, ref, caller); + if (!type) return null; + // A `->` on a value of the parameter's type is the argument's own `operator->`. + const line = this.lines(ref.filePath)[ref.line - 1] ?? ''; + const arrow = new RegExp(String.raw`\b${receiver}\s*->\s*(?:template\s+)?${member}\b`).test(line); + return arrow !== isCppPointerType(text!) ? null : { ...type, member, arity: null }; + } + + /** How a reference goes through a parameter, or null when it doesn't. */ + through(ref: UnresolvedRef, caller: Node, types: ReadonlySet): Through | null { + const value = VALUE_CALL.exec(ref.referenceName); + if (value) return this.throughValue(ref, caller, value[1]!, value[2]!, types); + const constructor = /^(.*)::([^:]+)\/(\d+)$/.exec(ref.referenceName); + const segments = (constructor ? constructor[1]! : ref.referenceName).replace(/::\s*template\s+/g, '::').split('::'); + if (!segments.every((s) => IDENTIFIER.test(s))) return null; + const type = this.parameterOf(segments, ref, caller); + if (!type) return null; + if (constructor) return { ...type, member: null, arity: Number(constructor[3]) }; + if (ref.referenceKind === 'instantiates') return { ...type, member: null, arity: null }; + return { ...type, types: type.types.slice(0, -1), member: type.types[type.types.length - 1]!, arity: null }; + } + + private source(file: string): Source | null { + let source = this.sources.get(file); + if (source === undefined) { + const raw = this.ctx.readFile(file); + const text = raw === null ? null : stripCommentsForRegex(raw, 'cpp'); + source = text === null ? null : { text, lineAt: makeLineAt(text, 1) }; + this.sources.set(file, source); + } + return source; + } + + private declarationsIn(file: string): Node[] { + let decls = this.declarations.get(file); + if (!decls) { + decls = this.ctx.getNodesInFile(file).filter((n) => DECLARATION_KINDS.has(n.kind) && (n.language === 'cpp' || n.language === 'c')); + this.declarations.set(file, decls); + } + return decls; + } + + /** The tightest declaration whose lines hold `line`. */ + private declarationAt(file: string, line: number): Node | null { + let best: Node | null = null; + for (const n of this.declarationsIn(file)) { + if (n.startLine > line || (n.endLine ?? n.startLine) < line) continue; + if (!best || n.startLine > best.startLine || (n.startLine === best.startLine && (n.endLine ?? n.startLine) < (best.endLine ?? best.startLine))) best = n; + } + return best; + } + + /** The namespace at `offset`, outside every declaration: the index holds no node for one. */ + private namespaceAt(file: string, offset: number, line: number): string { + const source = this.source(file); + if (!source) return ''; + let blocks = this.namespaces.get(file); + if (!blocks) this.namespaces.set(file, (blocks = namespaceBlocks(source.text))); + let path: readonly string[] = []; + for (const block of blocks) if (block.start <= offset && offset < block.end) path = block.path; + const macros = cppMacroNamespaceFrames(file, this.ctx).filter((f) => f.start <= line && f.end >= line).flatMap((f) => f.path); + return [...macros, ...path].join('::'); + } + + private placeIn(around: Node, line: number): Place { + const file = around.filePath; + const scope = CLASS_KINDS.has(around.kind) ? around.qualifiedName : cppParentScope(around.qualifiedName); + const ref: UnresolvedRef = { fromNodeId: around.id, referenceName: '', referenceKind: 'references', line, column: 0, filePath: file, language: 'cpp' }; + return { file, line, scope, around, templates: [], parameters: new Set(), ref }; + } + + private parametersOf(templates: readonly Template[]): Set { + const parameters = new Set(); + for (const t of templates) for (const p of t.parameters) if (p.name) parameters.add(p.name); + return parameters; + } + + /** Where a caller's call, or the body of a template, writes a name. */ + private placeOfCaller(caller: Node, line: number): Place { + const templates = this.templatesAround(caller); + return { ...this.placeIn(caller, line), templates, parameters: this.parametersOf(templates) }; + } + + /** Where the text at `offset` of `file` is written. */ + private placeAt(file: string, offset: number): Place { + const source = this.source(file)!; + const line = source.lineAt(offset); + const around = this.declarationAt(file, line); + const place: Place = around ? this.placeOfCaller(around, line) : { + file, line, around: null, templates: [], parameters: new Set(), + scope: this.namespaceAt(file, offset, line), + ref: { fromNodeId: '', referenceName: '', referenceKind: 'references', line, column: 0, filePath: file, language: 'cpp' }, + }; + // A head no node holds: a prototype's, or a default's in a head. + const start = Math.max(source.text.lastIndexOf(';', offset), source.text.lastIndexOf('{', offset), source.text.lastIndexOf('}', offset)) + 1; + const statement = source.text.slice(start, offset); + if (statement.includes('template')) { + const parameters = new Set(place.parameters); + for (const name of cppTypeNamesDeclared(`${statement}>`)) parameters.add(name); + place.parameters = parameters; + } + return place; + } + + private site(place: Place): CppNameSite { + return { scope: place.scope, file: place.file, line: place.line, parameters: place.parameters, sees: (n) => this.sees(place.file, n) }; + } + + /** + * Can code in `file` see `decl`: its own, what it includes, or a header + * beside it in one translation unit? A project's vendored copy of itself + * declares the same names, and nothing it declares is any other file's. + */ + private sees(file: string, decl: Node): boolean { + if (decl.filePath === file) return true; + const key = `${file}\0${decl.filePath}`; + let seen = this.seen.get(key); + if (seen === undefined) { + const includers = cppIncluders(decl.filePath, this.ctx); + seen = includers.has(file) || cppIncluders(file, this.ctx).has(decl.filePath); + if (!seen) for (const unit of cppIncluders(file, this.ctx)) if (includers.has(unit)) { seen = true; break; } + this.seen.set(key, seen); + } + return seen; + } + + /** Is the line a preprocessor directive, or a continuation of one? */ + private isDirective(file: string, line: number): boolean { + const lines = this.lines(file); + let at = line - 1; + while (at > 0 && /\\\s*$/.test(lines[at - 1] ?? '')) at--; + return /^\s*#/.test(lines[at] ?? ''); + } + + /** Is what the line holds at the column code, not a string or a comment? */ + private isCode(file: string, line: number, column: number, name: string): boolean { + return cppLiteralsBlanked(this.lines(file)[line - 1] ?? '', line, cppNonCodeLines(file, this.ctx)).startsWith(name, column); + } + + /** + * Is the declaration a lookup found in `file` `template`? Two headers can + * declare one name for separate translation units, and so can two source + * files: abseil's map and set tests each have a `ConstructorTest`. + */ + private isTemplate(found: Node, template: Template, file: string): boolean { + return found.qualifiedName === template.node.qualifiedName && this.sees(file, template.node); + } + + /** Every `name<…>` a class or alias template is written as, and its specializations, in file and offset order. */ + private async writtenAs(template: Template): Promise { + const hit = this.written.get(template.key); + if (hit) return hit; + const out: WrittenTemplate = { instantiations: [], specializations: [] }; + this.written.set(template.key, out); + const { name } = template; + for (const file of this.filesNaming.get(name) ?? []) { + if ((++this.scanned & 15) === 0) await this.onYield(); + const source = this.source(file); + if (!source) continue; + const { text } = source; + for (let at = text.indexOf(name); at >= 0; at = text.indexOf(name, at + name.length)) { + if (at > 0 && isWord(text.charCodeAt(at - 1))) continue; + let open = at + name.length; + while (open < text.length && isSpace(text.charCodeAt(open))) open++; + if (text[open] !== '<') continue; + const close = closingAngle(text, open); + if (close < 0) continue; + const before = text.slice(Math.max(0, at - HEAD_WINDOW), at); + // (`x.get()` and `p->get()` call a member template.) + if (/(?:\.|->)\s*(?:template\s+)?$/.test(before) || /\bfriend\s+(?:class|struct)\s+[\w:\s]*$/.test(before)) continue; + const line = source.lineAt(at); + if (this.isDirective(file, line) || !this.isCode(file, line, at - text.lastIndexOf('\n', at) - 1, name)) continue; + const place = this.placeAt(file, at); + const written = [...qualifiersBefore(text, at).map((q) => q.name), name].join('::'); + const site = template.kind === 'alias' ? { ...this.site(place), declared: true } : this.site(place); + const found = cppClassWritten(written, site, place.ref, this.ctx); + if (!found?.exact || !this.isTemplate(found.node, template, file)) continue; + const args = argumentsBetween(text, open, close).map((a) => ({ text: a.text, place: { ...place, line: source.lineAt(a.offset) } })); + const after = text.slice(close + 1, close + 8); + // `template class Name;` instantiates; `template <…> class Name {` specializes. + const keyword = CLASS_HEAD.exec(before); + const head = keyword && /^\s*(?:[{;]|:(?!:)|final\b)/.test(after) ? /\btemplate\s*<([^;{}]*)>\s*$/.exec(before.slice(0, keyword.index)) : null; + if (head) { + out.specializations.push(specializationOf(args.map((a) => argumentKey(a.text)), cppTemplateParametersIn(head[1]!))); + continue; + } + out.instantiations.push({ args }); + } + } + if (template.kind === 'class') for (const list of this.typedLists(template)) out.instantiations.push(list); + return out; + } + + /** gtest's typed fixtures, by their last name, with where each is given its list of types. */ + private typedSuites(): Map> { + if (this.suites) return this.suites; + const suites = new Map>(); + for (const file of this.typedFiles) { + const source = this.source(file); + if (!source) continue; + for (const m of source.text.matchAll(TYPED_SUITE)) { + if (this.isDirective(file, source.lineAt(m.index!))) continue; + const fixture = m[1]!.replace(/\s+/g, ''); + const last = fixture.slice(fixture.lastIndexOf(':') + 1); + const list = suites.get(last); + const suite = { file, offset: m.index! + m[0].length, fixture }; + if (list) list.push(suite); + else suites.set(last, [suite]); + } + } + this.suites = suites; + return suites; + } + + /** The instantiations `TYPED_TEST_SUITE(Fixture, Types)` writes for a fixture: one per listed type. */ + private typedLists(template: Template): Instantiation[] { + const out: Instantiation[] = []; + for (const suite of this.typedSuites().get(template.name) ?? []) { + const place = this.placeAt(suite.file, suite.offset); + const found = cppClassWritten(suite.fixture, this.site(place), place.ref, this.ctx); + if (!found?.exact || !this.isTemplate(found.node, template, suite.file)) continue; + for (const arg of this.typeList(suite.file, suite.offset, 0)) out.push({ args: [arg] }); + } + return out; + } + + /** The types a `::testing::Types<…>` written at `offset` of `file` lists, directly or through an alias of it. */ + private typeList(file: string, offset: number, hops: number): Argument[] { + const source = this.source(file); + if (!source || hops > 3) return []; + const { text } = source; + const from = text.slice(offset, offset + HEAD_WINDOW); + const list = /\bTypes\s* ({ text: a.text, place: { ...place, line: source.lineAt(a.offset) } })); + } + const name = from.slice(0, end).replace(/\s+/g, ''); + if (!cppTypeSegments(name)) return []; + const place = this.placeAt(file, offset); + const found = cppClassWritten(name, { ...this.site(place), declared: true }, place.ref, this.ctx); + if (!found || found.node.kind !== 'type_alias') return []; + const alias = found.node; + const aliased = this.source(alias.filePath); + if (!aliased) return []; + let start = 0; + for (let i = 1; i < alias.startLine; i++) start = aliased.text.indexOf('\n', start) + 1; + return this.typeList(alias.filePath, start + alias.startColumn, hops + 1); + } + + /** A function template's calls, in file and line order, with the arguments each writes. */ + private callsOf(template: Template): readonly Call[] { + const hit = this.calls.get(template.key); + if (hit) return hit; + const out: Call[] = []; + for (const edge of this.queries.getIncomingEdges(template.node.id, ['calls', 'references'])) { + const caller = this.ctx.getNodeById?.(edge.source); + if (!caller || edge.line === undefined || edge.line === null || !this.sees(caller.filePath, template.node)) continue; + const ref: UnresolvedRef = { + fromNodeId: caller.id, referenceName: template.name, referenceKind: 'calls', + line: edge.line, column: edge.column ?? 0, filePath: caller.filePath, language: 'cpp', + }; + const place = this.placeOfCaller(caller, edge.line); + const args = cppCallTemplateArguments(ref, this.ctx)?.map((text) => ({ text, place })) ?? null; + out.push({ ref, place, args }); + } + out.sort((a, b) => (a.ref.filePath < b.ref.filePath ? -1 : a.ref.filePath > b.ref.filePath ? 1 : 0) || a.ref.line - b.ref.line || a.ref.column - b.ref.column); + this.calls.set(template.key, out); + return out; + } + + /** + * The argument a call deduces for a parameter a function's own parameter + * has as its whole type (`T`, `const T&`, `T&&`, `T*`), when the call's + * argument there has a written type: the deduction has nothing else to go + * on, and what it deduces is exact. + */ + private deduced(template: Template, index: number, call: Call): Argument | null { + const name = template.parameters[index]!.name; + if (!name) return null; + const list = cppOverloadParameters(template.node, template.name, this.ctx); + if (list === null) return null; + const type = new RegExp(String.raw`^(?:const\s+)?${escapeRegExp(name)}\s*(?:const\s*)?(\*|&&?)?\s*(?:const\s+)?(?:[A-Za-z_]\w*)?\s*(?:=.*)?$`, 's'); + const parameters = splitCppTopLevel(list); + const position = parameters.findIndex((p) => type.test(p.trim())); + if (position < 0) return null; + const given = cppCallArguments(call.ref, template.name, this.ctx)?.[position]; + const argument = given === undefined ? null : cppArgumentType(given, call.ref, this.ctx); + if (argument?.kind !== 'type') return null; + const value = argument.text.replace(/&+\s*$/, '').trim(); + if (type.exec(parameters[position]!.trim())?.[1] !== '*') return { text: value, place: call.place }; + return /\*\s*(?:const\s*)?$/.test(value) ? { text: value.replace(/\*\s*(?:const\s*)?$/, '').trim(), place: call.place } : null; + } + + /** The arguments a site gives a parameter of the template itself; its default stands in for one it doesn't write. */ + private argumentsFor(slot: Slot, args: readonly Argument[]): readonly Argument[] { + const parameter = slot.template.parameters[slot.index]!; + if (parameter.pack) return args.slice(slot.index); + const given = args[slot.index]; + if (given) return [given]; + if (parameter.fallback === null || slot.template.kind === 'function') return []; + const decl = slot.template.node; + return [{ text: parameter.fallback, place: this.placeOfCaller(decl, decl.startLine) }]; + } + + /** + * What the specialization binds its parameters to, when `args` can be taken + * by it: `Foo` for `T` of `Name` given `Name`. Null when what each + * side writes tells it isn't taken. A condition (`enable_if_t<…>`) can't be + * told from what is written, and counts as taken. + */ + private bindings(spec: Specialization, primary: readonly CppTemplateParameter[], args: readonly string[]): Map | null { + const bound = new Map(); + for (let i = 0; i < spec.pattern.length; i++) { + const item = spec.pattern[i]!; + if (item.parameter) { + const given = item.pack ? args.slice(i) : args[i] !== undefined ? [args[i]!] : []; + bound.set(item.parameter, [...(bound.get(item.parameter) ?? []), ...given]); + continue; + } + if (item.condition) continue; + const given = args[i] ?? (primary[i]?.fallback ? argumentKey(primary[i]!.fallback!) : null); + if (given === null) return null; + if (!item.shape) { + if (given !== item.text && !given.endsWith(`::${item.text}`) && !item.text.endsWith(`::${given}`)) return null; + continue; + } + const m = item.shape.exec(given); + if (!m) return null; + item.words.forEach((w, k) => bound.set(w, [m[k + 1]!])); + } + return bound; + } + + /** The arguments each instantiation gives a parameter, once specializations have taken theirs. */ + private async argumentsOf(slot: Slot): Promise { + const { template, index } = slot; + if (template.kind === 'function') { + return this.callsOf(template).flatMap((call) => { + const written = this.argumentsFor(slot, call.args ?? []); + const deduced = written.length === 0 ? this.deduced(template, index, call) : null; + return deduced ? [deduced] : written; + }); + } + const { instantiations, specializations } = await this.writtenAs(template); + const primary = specializations.length === 0 ? template.parameters : this.primaryParameters(template); + const own = template.pattern?.map(argumentKey).join() ?? null; + const out: Argument[] = []; + for (const site of instantiations) { + const given = specializations.length > 0 ? site.args.map((a) => argumentKey(a.text)) : []; + const taken = specializations.flatMap((spec) => { + const bound = this.bindings(spec, primary, given); + return bound ? [{ spec, bound }] : []; + }); + if (own === null) { + if (taken.length === 0) out.push(...this.argumentsFor(slot, site.args)); + continue; + } + // A specialization's own members, from the instantiations only it takes. + if (taken.length !== 1 || taken[0]!.spec.written !== own) continue; + const name = template.parameters[index]?.name; + const place = site.args[0]?.place; + if (name && place) for (const text of taken[0]!.bound.get(name) ?? []) out.push({ text, place }); + } + return out; + } + + /** The head of the template itself, whichever declaration a reference is in. */ + private primaryParameters(template: Template): readonly CppTemplateParameter[] { + if (!template.pattern) return template.parameters; + for (const decl of this.classesNamed(template.node.qualifiedName)) { + const heads = cppTemplateHeads(decl, this.ctx); + const head = heads[heads.length - 1]; + if (head && !this.templateOfClass(decl, head)?.pattern) return head; + } + return []; + } + + /** + * The classes a parameter reaches, each once, with the argument that writes + * it: those its own instantiations write, then those of each template whose + * parameter they pass on, a hop at a time and each parameter once, so a + * cycle ends. It stops past the cap: all that is known then is the cap. + */ + async classesOf(start: Slot): Promise> { + const classes = new Map(); + const seen = new Set([slotKey(start)]); + let frontier = [start]; + for (let hop = 0; hop <= MAX_TEMPLATE_HOPS && frontier.length > 0; hop++) { + const next: Slot[] = []; + for (const slot of frontier) { + for (const arg of await this.argumentsOf(slot)) { + const text = arg.text.replace(/\s*\.\.\.$/, '').trim(); + const name = text.replace(/\b(?:const|volatile|typename)\b/g, ' ').trim(); + const outer = IDENTIFIER.test(name) ? this.slotNamed(name, arg.place.templates) : null; + if (outer) { + if (!seen.has(slotKey(outer))) next.push(outer); + seen.add(slotKey(outer)); + continue; + } + const node = this.classWritten(text, arg.place); + if (node && !classes.has(node.id)) classes.set(node.id, { node, at: arg }); + if (classes.size > CPP_TEMPLATE_ARGUMENT_CAP) return classes; + } + } + frontier = next; + } + return classes; + } + + /** The project class an argument names, or null. */ + private classWritten(text: string, place: Place): Node | null { + const plain = stripCppTemplateArguments(text); + if (/[&*()[\]]/.test(plain) || isCppPointerType(text) || /\bdecltype\b/.test(plain)) return null; + const segments = cppTypeSegments(text); + if (!segments) return null; + if (place.around && FUNCTION_KINDS.has(place.around.kind)) { + const fn = place.around; + if (segments.length === 1) { + // A class local to the function that writes it, which the index names after the function alone. + const local = this.declarationsIn(place.file).filter((n) => CLASS_KINDS.has(n.kind) && n.name === segments[0] && + n.startLine >= fn.startLine && (n.endLine ?? n.startLine) <= (fn.endLine ?? fn.startLine) && n.startLine <= place.line); + if (local.length > 0) return local[local.length - 1]!; + } + if (cppDeclaresLocalAlias(text, place.ref, this.ctx)) { + const aliased = resolveCppAliasedType(text, place.ref, this.ctx); + const named = aliased && !aliased.pointer ? cppAliasedClass(aliased, this.ctx, place.ref) : undefined; + return named ? this.classesNamed(named).find((n) => this.sees(place.file, n)) ?? null : null; + } + } + const found = cppClassWritten(text, this.site(place), place.ref, this.ctx); + // (A lookup falls back on what it can't see when that is all a name has: a copy + // of the project declares it plainly where the project's own macro hides it.) + return found?.exact && CLASS_KINDS.has(found.node.kind) && this.sees(place.file, found.node) ? found.node : null; + } + + /** What a reference through a parameter names in the class its argument is, or null. */ + target(cls: Node, through: Through, ref: UnresolvedRef): Node | null { + const { types, member, arity } = through; + const spelled = types.join('::'); + const visible = (n: Node): boolean => this.sees(cls.filePath, n); + if (member === null && arity === null) { + if (types.length === 0) return cls; + const named = cppTypeNamed(spelled, [cls.qualifiedName], ref, this.ctx, visible); + if (!named) return null; + if ('alias' in named) return named.alias; + if ('enums' in named) return named.enums[0] ?? null; + return this.classNode(named.cls, cls); + } + let owner: Node | null = cls; + if (types.length > 0) { + const named = cppClassNamed(spelled, [cls.qualifiedName], ref, this.ctx, visible); + owner = named && 'cls' in named ? this.classNode(named.cls, cls) : null; + } + if (!owner) return null; + if (arity !== null) { + const made = matchCppConstructor({ ...ref, referenceName: `::${owner.qualifiedName}::${owner.name}/${arity}` }, this.ctx); + return made ? this.ctx.getNodeById?.(made.targetNodeId) ?? null : null; + } + return cppMethodOf(owner, member!, ref, this.ctx); + } + + private classNode(qualified: string, near: Node): Node | null { + const classes = this.classesNamed(qualified); + return classes.find((n) => n.filePath === near.filePath) ?? classes.find((n) => this.sees(near.filePath, n)) ?? null; + } +} + +const position = (a: UnresolvedRef, b: UnresolvedRef): number => + (a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0) || a.line - b.line || a.column - b.column || + (a.referenceName < b.referenceName ? -1 : a.referenceName > b.referenceName ? 1 : 0) || + (a.referenceKind < b.referenceKind ? -1 : a.referenceKind > b.referenceKind ? 1 : 0); + +const EDGE_KIND: Readonly> = { calls: 'calls', instantiates: 'instantiates', function_ref: 'references' }; + +/** + * One edge per class a failed reference through a template parameter reaches + * (see the top of this file). Reads the graph and source only. + */ +export async function cppTemplateArgumentEdges( + queries: QueryBuilder, + ctx: ResolutionContext, + onYield: MaybeYield, +): Promise { + const reader = new TemplateInstantiations(queries, ctx, onYield); + // A name in a template's head or an alias, by file: most references go no further. + const declared = await reader.read(); + if (declared.size === 0) return []; + const refs: UnresolvedRef[] = []; + for (const ref of queries.iterateFailedCppTypeReferences()) { + const names = declared.get(ref.filePath as string); + const head = /^[A-Za-z_]\w*/.exec(ref.referenceName)?.[0]; + if (names && head && (names.has(head) || ref.referenceName.includes('.'))) refs.push(ref as UnresolvedRef); + } + refs.sort(position); + const groups = new Map }>(); + let scanned = 0; + for (const ref of refs) { + if ((++scanned & 255) === 0) await onYield(); + const caller = ctx.getNodeById?.(ref.fromNodeId); + if (!caller) continue; + const through = reader.through(ref, caller, declared.get(ref.filePath)!); + if (!through) continue; + const key = slotKey(through.slot); + const group = groups.get(key); + if (group) group.refs.push({ ref, through }); + else groups.set(key, { slot: through.slot, refs: [{ ref, through }] }); + } + const edges: Edge[] = []; + for (const group of groups.values()) { + await onYield(); + const classes = await reader.classesOf(group.slot); + if (classes.size > CPP_TEMPLATE_ARGUMENT_CAP) continue; + for (const { ref, through } of group.refs) { + for (const { node, at } of classes.values()) { + const target = reader.target(node, through, ref); + if (!target || target.id === ref.fromNodeId) continue; + edges.push({ + source: ref.fromNodeId, + target: target.id, + kind: EDGE_KIND[ref.referenceKind]!, + line: ref.line, + column: ref.column, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'cpp-template-argument', + via: through.parameter, + argument: node.name, + registeredAt: `${at.place.file}:${at.place.line}`, + ...(ref.referenceKind === 'function_ref' ? { fnRef: true } : {}), + }, + }); + } + } + } + return edges; +} diff --git a/src/resolution/cpp-type-aliases.ts b/src/resolution/cpp-type-aliases.ts index e28709d6d..217bfdabb 100644 --- a/src/resolution/cpp-type-aliases.ts +++ b/src/resolution/cpp-type-aliases.ts @@ -400,15 +400,21 @@ export function clearCppTypeAliasMemos(context: ResolutionContext): void { LOCAL_ALIAS_LINES.delete(context); } +/** + * What may come between a `template <…>` head and where its declaration + * starts: protobuf's `template ` / + * `PROTOBUF_NDEBUG_INLINE static T* Create(…)`. + */ +const HEAD_SPECIFIERS = String.raw`(?:\s*(?:\[\[[^\]]*\]\]|(?:static|inline|constexpr|consteval|virtual|explicit|friend|extern)\b|[A-Z][A-Z0-9]*_[A-Z0-9_]*\b(?:\s*\([^()]*\))?))*\s*`; +const HEAD_ENDS_DECLARATION = new RegExp(String.raw`^template\s*<([\s\S]*)>${HEAD_SPECIFIERS}$`); +const ONLY_SPECIFIERS = new RegExp(`^${HEAD_SPECIFIERS}$`); + /** Add the parameter names of the `template <…>` header that ends right where a declaration starts. */ function collectTemplateHeader(lines: readonly string[], startLine: number, startColumn: number, names: Set): void { // (A header can take several lines: fmt's `join` spends four on its parameters' defaults.) const before = [...lines.slice(Math.max(0, startLine - 9), startLine - 1), (lines[startLine - 1] ?? '').slice(0, startColumn)].join('\n'); const at = before.lastIndexOf('template'); - // Specifiers, attributes and macros may come between the header and where - // the declaration starts: protobuf's `template ` / `PROTOBUF_NDEBUG_INLINE static T* Create(…)`. - const header = at < 0 ? null : /^template\s*<([\s\S]*)>(?:\s*(?:\[\[[^\]]*\]\]|(?:static|inline|constexpr|consteval|virtual|explicit|friend|extern)\b|[A-Z][A-Z0-9]*_[A-Z0-9_]*\b(?:\s*\([^()]*\))?))*\s*$/.exec(before.slice(at)); + const header = at < 0 ? null : HEAD_ENDS_DECLARATION.exec(before.slice(at)); if (!header) return; for (const item of splitTopLevel(header[1]!)) addTemplateParameter(item, names); } @@ -792,6 +798,76 @@ export function cppTemplateParameters(node: Node, context: ResolutionContext): R return templateParametersAround(node, context); } +/** One parameter of a C++ `template <…>` head, in order: an unnamed one holds its place. */ +export interface CppTemplateParameter { + name: string | null; + /** `typename... Ts`: every argument from here on. */ + pack: boolean; + /** Its default, as written: `TcParser` of `typename Parser = TcParser`. */ + fallback: string | null; +} + +/** One item of a `template <…>` head. */ +function templateParameter(item: string): CppTemplateParameter { + const declared = stripCppTemplateArguments(item); + const at = declared.indexOf('='); + const name = /([A-Za-z_]\w*)\s*$/.exec(at < 0 ? declared : declared.slice(0, at))?.[1]; + // (A default can hold a `=` of its own, so it is read from the item as written.) + const eq = item.indexOf('='); + return { + name: name && name !== 'typename' && name !== 'class' ? name : null, + pack: declared.includes('...'), + fallback: at < 0 || eq < 0 ? null : item.slice(eq + 1).trim(), + }; +} + +/** The parameters of a `template <…>` head, from what its brackets hold. */ +export function cppTemplateParametersIn(inner: string): CppTemplateParameter[] { + return inner.trim() ? splitTopLevel(inner).map(templateParameter) : []; +} + +/** + * The `template <…>` heads right before a declaration, outermost first, each + * with its parameters in order: one for a template, none for anything else, + * and two for a member template defined outside its class template + * (`template template void C::f()`). + */ +export function cppTemplateHeads(node: Node, context: ResolutionContext): CppTemplateParameter[][] { + const lines = linesOf(node.filePath, context); + if (!lines) return []; + const before = [...lines.slice(Math.max(0, node.startLine - 13), node.startLine - 1), (lines[node.startLine - 1] ?? '').slice(0, node.startColumn)].join('\n'); + const heads: CppTemplateParameter[][] = []; + const starts = [...before.matchAll(/\btemplate\s*= 0; i--) { + const open = starts[i]!.index! + starts[i]![0].length - 1; + const close = templateHeaderEnd(before, open); + if (close < 0 || !ONLY_SPECIFIERS.test(before.slice(close + 1, end))) break; + const inner = before.slice(open + 1, close); + heads.unshift(cppTemplateParametersIn(inner)); + end = starts[i]!.index!; + } + return heads; +} + +/** + * The type an alias in scope where `ref` is made declares, as written, with + * the alias when the index holds one: `typename TypeParam::KeyGen` for + * `using KeyGen = typename TypeParam::KeyGen;` in the calling function, or an + * alias of the calling function's class. Undefined when no such alias has + * that name. + */ +export function cppAliasWrittenAt(name: string, ref: UnresolvedRef, context: ResolutionContext): { text: string; alias?: Node } | undefined { + const caller = context.getNodeById?.(ref.fromNodeId); + const local = localAliasedType(name, caller, ref, context); + if (local !== undefined) return local === null ? undefined : { text: local }; + const found = lookupUnqualified(name, false, scopesWithin(callerScope(caller)), ref, context); + if (!found || typeof found !== 'object' || !('alias' in found)) return undefined; + const text = declarationText(found.alias, context); + const written = text ? aliasedTypeIn(text, found.alias.name) : null; + return written ? { text: written, alias: found.alias } : undefined; +} + /** * What a written name declares, looked up from `scopes` (innermost first), and * how many of its segments that took: a class's member is looked up in the diff --git a/src/resolution/index.ts b/src/resolution/index.ts index a0196577d..9a711db53 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -805,6 +805,16 @@ export class ReferenceResolver { } } + /** The text a walk over the indexed files reads: see ResolutionContext.scanFile. */ + private scanFile(filePath: string): string | null { + if (this.fileCache.has(filePath)) return this.fileCache.get(filePath) ?? null; + try { + return fs.readFileSync(path.join(this.projectRoot, filePath), 'latin1'); + } catch { + return null; + } + } + /** * Create the resolution context */ @@ -992,6 +1002,7 @@ export class ReferenceResolver { readFile: (filePath: string) => this.readFileCached(filePath), fileContains: (filePath: string, needle: string) => this.fileContains(filePath, needle), + scanFile: (filePath: string) => this.scanFile(filePath), getFileLines: (filePath: string) => { const cached = this.fileLinesCache.get(filePath); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5d4e0cc8e..a84db0fe0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -17369,7 +17369,7 @@ const CPP_CALLERS_MEMBERS = new WeakMap()): Node | null { +export function cppMethodOf(cls: Node, name: string, ref: UnresolvedRef, context: ResolutionContext, depth = 0, seen = new Set()): Node | null { const want = `${cls.qualifiedName}::${name}`; const named = context.getMethodMatches?.(cls.name, name, ref.language) ?? context.getNodesByName(name).filter((n) => n.kind === 'method' && sameLanguageFamily(n.language, ref.language)); @@ -19251,7 +19251,7 @@ export function cppSpecializationArguments(n: Node, context: ResolutionContext): * calls — `unsigned short` for fmt's `num_bits()` — or null * when it writes none, leaving them to be deduced. */ -function cppCallTemplateArguments(ref: UnresolvedRef, context: ResolutionContext): string[] | null { +export function cppCallTemplateArguments(ref: UnresolvedRef, context: ResolutionContext): string[] | null { const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []; const text = lines.slice(ref.line - 1, ref.line + 3).join('\n'); return cppTemplateArgumentsAfter(text, ref.referenceName.split(/::|\.|->/).pop()!, Math.max(0, ref.column)); @@ -19327,7 +19327,7 @@ type CppArgumentType = * `TryLock(column_family[i], keys[i], …)` on a `std::vector`. Null * otherwise. (String literals are cppOverloadFit's.) */ -function cppArgumentType(arg: string, ref: UnresolvedRef, context: ResolutionContext): CppArgumentType | null { +export function cppArgumentType(arg: string, ref: UnresolvedRef, context: ResolutionContext): CppArgumentType | null { const text = arg.trim(); if (/^(?:u8|u|U|L)?'(?:[^'\\]|\\.)+'$/.test(text)) return { kind: 'char' }; if (text === 'true' || text === 'false') return { kind: 'bool' }; @@ -19644,7 +19644,7 @@ function isCppDependentBase(decl: Node, base: Node, context: ResolutionContext): * declaration in an earlier block the call is outside of counts too: the * name strategies decide, as before. */ -function isCppLocalName(name: string, caller: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { +export function isCppLocalName(name: string, caller: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { if (caller.filePath !== ref.filePath || CPP_CLASS_KINDS.has(caller.kind)) return false; const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); if (!lines) return false; @@ -32833,7 +32833,7 @@ function matchCppMacroNamespaced(ref: UnresolvedRef, context: ResolutionContext) } /** Split `a, f(b, c), d` at its top-level commas. */ -function splitCppTopLevel(text: string): string[] { +export function splitCppTopLevel(text: string): string[] { const out: string[] = []; let depth = 0; // Where each template argument list open around the current character closes. @@ -32950,7 +32950,7 @@ function cppWithoutComments(text: string): string { return out; } -function cppCallArguments(ref: UnresolvedRef, name: string, context: ResolutionContext): string[] | null { +export function cppCallArguments(ref: UnresolvedRef, name: string, context: ResolutionContext): string[] | null { const list = cppParenListAfter(ref.filePath, ref.line, Math.max(0, ref.column), name, context); return list === null ? null : splitCppTopLevel(cppWithoutComments(list)); } @@ -32968,7 +32968,7 @@ const CPP_OVERLOAD_PARAMETERS = new WeakMap