From 5844eb8986a89065b2b5dcdc489e968722e472dd Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 16:38:59 +0000 Subject: [PATCH] fix(cpp): a qualified name reaches a declaration in a macro-opened namespace (#2176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmt opens its namespace with FMT_BEGIN_NAMESPACE, pybind11 with PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE), rapidjson/spdlog with …_NAMESPACE_BEGIN — the extractor sees a bare macro line, so every declaration inside is indexed without its namespace and fmt::format(…) (1,728 calls on fmt) resolved to nothing. As a last resort for a qualified C/C++ name, read the project's namespace macros (object-like and single-parameter function-like openers, through identifier aliases, inline namespaces transparent; closers by brace count), the ranges each opens per file, and namespace aliases (namespace py = pybind11;), and match the declaration whose macro prefix + qualified name is the name called. fmt +2480, pybind11 +1446, spdlog +264; tokio/serde/gin/excalidraw/Newtonsoft unchanged. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-macro-namespace.test.ts | 90 +++++++++++++++++ src/resolution/name-matcher.ts | 138 ++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 __tests__/cpp-macro-namespace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e5da665..426611325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In C and C++, a qualified name like `fmt::format` now reaches a declaration inside a namespace the library opens with a macro, like fmt's `FMT_BEGIN_NAMESPACE`, pybind11's `PYBIND11_NAMESPACE_BEGIN(…)` or rapidjson's and spdlog's `…_NAMESPACE_BEGIN`. It also works through a namespace alias like `namespace py = pybind11;`. On fmt, 1,728 `fmt::format(…)` calls used to link to nothing, and so did about 1,400 `py::…` calls on pybind11. - A method guessed from a receiver's name no longer lands on a request handler the web framework dispatches to, such as a Django or DRF view's `post` / `get` or a controller's `update` / `destroy`. It also no longer lands on a test double the calling file never mentions. On django-allauth, tests' `client.post(…)` used to land on a `ClientRegistrationView` 425 times. On mealie, `response.json()` used to land on a test's `_FakeHTTPResponse` 424 times. In Python, `site = Site.objects.create(…)` now makes `site` a `Site`, so `site.save()` reaches the model's own `save`. Re-index after upgrading. - Kotlin infix calls, like `Users.id eq id1` or koin's `single { … } bind MyApi::class`, are now recorded as calls to the infix function. Before, they had no callers at all. On Exposed, `eq` now shows over a thousand callers. Bit operations on numbers (`x and 0xff`, `h shr 8`) are left to Kotlin's own operators, even where the project defines a same-named extension. Re-index after upgrading. - Kotlin code using an infix function whose name starts with `e`, like Exposed's `Users.id eq id1`, now parses cleanly. Before, the rest of such a line was a parse error, and in a file with many of them the whole class around it could be lost. On Exposed, files with a parse error dropped from 229 to 71. Re-index after upgrading. diff --git a/__tests__/cpp-macro-namespace.test.ts b/__tests__/cpp-macro-namespace.test.ts new file mode 100644 index 000000000..675766ec3 --- /dev/null +++ b/__tests__/cpp-macro-namespace.test.ts @@ -0,0 +1,90 @@ +/** + * A C++ library that opens its namespace with a macro — fmt's + * `FMT_BEGIN_NAMESPACE` (`namespace fmt { inline namespace v12 {`), + * pybind11's `PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)` — has its + * declarations indexed without that namespace, so `fmt::format(…)` found + * nothing: 1,728 calls on fmt. A qualified name now reaches a declaration the + * project's own namespace macros put there, through a namespace alias + * (`namespace py = pybind11;`) too. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cpp-macro-ns-')); + const files: Record = { + 'include/fmt/base.h': `#pragma once +#define FMT_BEGIN_NAMESPACE \\ + namespace fmt { \\ + inline namespace v12 { +#define FMT_END_NAMESPACE \\ + } \\ + } + +FMT_BEGIN_NAMESPACE +namespace detail { +inline int to_unsigned(int value) { return value; } +} + +inline int format(const char* spec) { return detail::to_unsigned(0); } +FMT_END_NAMESPACE +`, + 'include/pybind11/detail/macros.h': `#pragma once +#define PYBIND11_NAMESPACE pybind11 +#define PYBIND11_NAMESPACE_BEGIN(name) \\ + namespace name { \\ + PYBIND11_WARNING_PUSH +#define PYBIND11_NAMESPACE_END(name) \\ + PYBIND11_WARNING_POP \\ + } + +PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE) +inline void print(const char* text) {} +PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE) +`, + 'test/format-test.cc': `#include "fmt/base.h" +#include "pybind11/detail/macros.h" + +namespace py = pybind11; + +int main() { + fmt::format("{}"); + fmt::detail::to_unsigned(1); + py::print("hi"); + return 0; +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('C++ namespaces opened by a macro', () => { + it('put their declarations under the qualified names callers use', () => { + const ids = cg.getNodesInFile('test/format-test.cc').map((n) => n.id); + const targets = cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`) + .sort(); + expect(targets).toEqual([ + 'include/fmt/base.h:format', + 'include/fmt/base.h:to_unsigned', + 'include/pybind11/detail/macros.h:print', + ]); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 73269d4a7..c82419138 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4547,6 +4547,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { KOTLIN_RECEIVER_TYPES.delete(context); KOTLIN_HIERARCHIES.delete(context); KOTLIN_FRAMES.delete(context); + CPP_NS_MACROS.delete(context); + CPP_NS_FRAMES.delete(context); + CPP_NS_ALIASES.delete(context); MEMBER_TYPE_MEMO.delete(context); CSHARP_ALIASES.delete(context); SWIFT_HIERARCHIES.delete(context); @@ -7315,5 +7318,140 @@ function matchReferenceInner( result = nmTimed('fuzzy', ref, () => matchFuzzy(ref, context)); if (result) return result; + // 5. A C / C++ name qualified by a namespace the project opens with a macro + // (`fmt::format` — `FMT_BEGIN_NAMESPACE`), which the index cannot see. + if ((ref.language === 'cpp' || ref.language === 'c') && ref.referenceName.includes('::')) { + return matchCppMacroNamespaced(ref, context); + } + return null; } + +const CPP_NS_MACROS = new WeakMap; openerFns: Set; closers: Map; aliases: Map }>(); +const CPP_NS_FRAMES = new WeakMap>>(); +/** A closing macro's body: `}` / `} }`, maybe beside a pragma macro (`PYBIND11_WARNING_POP }`). */ +const CPP_CLOSER_BODY = /^(?:[A-Za-z_]\w*\s+)*\}(?:\s*\})*\s*;?$/; +const CPP_NS_ALIASES = new WeakMap>(); + +/** The project's namespace aliases: `namespace py = pybind11;`. */ +function cppNamespaceAliases(context: ResolutionContext): Map { + const hit = CPP_NS_ALIASES.get(context); + if (hit) return hit; + const aliases = new Map(); + for (const file of context.getAllFiles()) { + if (!/\.(?:h|hh|hpp|hxx|inl|c|cc|cpp|cxx)$/i.test(file)) continue; + const source = context.readFile(file); + if (!source || !source.includes('namespace')) continue; + for (const m of source.matchAll(/^[ \t]*namespace[ \t]+([A-Za-z_]\w*)[ \t]*=[ \t]*(?:::)?([A-Za-z_][\w:]*)[ \t]*;/gm)) { + if (!aliases.has(m[1]!)) aliases.set(m[1]!, m[2]!); + } + } + CPP_NS_ALIASES.set(context, aliases); + return aliases; +} + +/** + * The project's namespace-opening macros — `#define FMT_BEGIN_NAMESPACE + * namespace fmt { inline namespace v12 {`, `#define RAPIDJSON_NAMESPACE_BEGIN + * namespace RAPIDJSON_NAMESPACE {` (through `#define RAPIDJSON_NAMESPACE + * rapidjson`) — as the namespace path each opens (inline namespaces are + * transparent), and the closing macros as how many scopes each closes. + */ +function cppNamespaceMacros(context: ResolutionContext): { openers: Map; openerFns: Set; closers: Map; aliases: Map } { + const hit = CPP_NS_MACROS.get(context); + if (hit) return hit; + const openers = new Map(); + // `#define PYBIND11_NAMESPACE_BEGIN(name) namespace name {`, used as `PYBIND11_NAMESPACE_BEGIN(detail)`. + const openerFns = new Set(); + const closers = new Map(); + const aliases = new Map(); + const bodies: Array<[string, string]> = []; + for (const file of context.getAllFiles()) { + if (!/\.(?:h|hh|hpp|hxx|h\+\+|inl|ipp|tcc)$/i.test(file)) continue; + const raw = context.readFile(file); + if (!raw || !raw.includes('#') || !raw.includes('define')) continue; + const source = stripCommentsForRegex(raw.replace(/\\\r?\n/g, ' '), 'cpp'); + for (const m of source.matchAll(/^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)(\(\s*([A-Za-z_]\w*)?\s*\))?[ \t]+([^\n]*)$/gm)) { + const body = m[4]!.trim(); + if (m[2] !== undefined) { + // (a trailing pragma macro — `PYBIND11_WARNING_PUSH` — rides along) + if (m[3] && new RegExp(`^namespace\\s+${m[3]}\\s*\\{[\\w\\s]*$`).test(body)) openerFns.add(m[1]!); + else if (CPP_CLOSER_BODY.test(body)) closers.set(m[1]!, (body.match(/\}/g) ?? []).length); + continue; + } + if (/^[A-Za-z_]\w*$/.test(body)) aliases.set(m[1]!, body); + else if (CPP_CLOSER_BODY.test(body)) closers.set(m[1]!, (body.match(/\}/g) ?? []).length); + // An inline namespace (transparent, and often named by a macro call) is skipped. + else if (/^(?:inline\s+namespace\s+[^{}]*\{\s*|namespace\s+[A-Za-z_]\w*\s*\{\s*)+[\w\s]*$/.test(body)) bodies.push([m[1]!, body]); + } + } + for (const [name, body] of bodies) { + if (openers.has(name)) continue; + const path = [...body.replace(/inline\s+namespace\s+[^{}]*\{/g, '').matchAll(/namespace\s+([A-Za-z_]\w*)/g)] + .map((m) => aliases.get(m[1]!) ?? m[1]!); + if (path.length > 0) openers.set(name, path); + } + const macros = { openers, openerFns, closers, aliases }; + CPP_NS_MACROS.set(context, macros); + return macros; +} + +/** The line ranges of a C / C++ file each namespace macro opens, with the namespace path it opens. */ +function cppMacroNamespaceFrames(file: string, context: ResolutionContext): Array<{ start: number; end: number; path: string[] }> { + let memo = CPP_NS_FRAMES.get(context); + if (!memo) { + memo = new Map(); + CPP_NS_FRAMES.set(context, memo); + } + const hit = memo.get(file); + if (hit) return hit; + const frames: Array<{ start: number; end: number; path: string[] }> = []; + const { openers, openerFns, closers, aliases } = cppNamespaceMacros(context); + if (openers.size > 0 || openerFns.size > 0) { + const lines = context.getFileLines?.(file) ?? context.readFile(file)?.split(/\r?\n/) ?? []; + const open: Array<{ start: number; path: string[] }> = []; + lines.forEach((text, i) => { + const m = /^[ \t]*([A-Z_][A-Z0-9_]*)(?:\(\s*([A-Za-z_]\w*)?\s*\))?[ \t]*;?[ \t]*(?:\/\/.*|\/\*.*\*\/[ \t]*)?\r?$/.exec(text); + const token = m?.[1]; + if (!token) return; + const arg = m[2]; + const path = arg !== undefined && openerFns.has(token) ? [aliases.get(arg) ?? arg] : arg === undefined ? openers.get(token) : undefined; + if (path) open.push({ start: i + 1, path }); + else if (closers.has(token) && open.length > 0) frames.push({ ...open.pop()!, end: i + 1 }); + }); + for (const frame of open) frames.push({ ...frame, end: lines.length }); + } + memo.set(file, frames); + return frames; +} + +/** + * Resolve `fmt::format` / `fmt::detail::to_unsigned` to the declaration a + * namespace macro puts there: a node named `format` (qualified `format` or + * `detail::to_unsigned` in the index, which cannot see the macro) inside a + * `FMT_BEGIN_NAMESPACE` … `FMT_END_NAMESPACE` range. On fmt, 1,728 + * `fmt::format(…)` calls resolved to nothing. + */ +function matchCppMacroNamespaced(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + let target = ref.referenceName.replace(/^::/, ''); + const head = target.slice(0, target.indexOf('::')); + const alias = cppNamespaceAliases(context).get(head); + // `py::str` under `namespace py = pybind11;` is `pybind11::str`. + if (alias) target = alias + target.slice(head.length); + const name = target.slice(target.lastIndexOf('::') + 2); + if (!/^[A-Za-z_~]\w*$/.test(name)) return null; + let best: Node | null = null; + for (const n of context.getNodesByName(name)) { + if (n.language !== 'cpp' && n.language !== 'c') continue; + if (!['function', 'method', 'class', 'struct', 'enum', 'type_alias', 'union', 'variable', 'constant'].includes(n.kind)) continue; + const prefix = cppMacroNamespaceFrames(n.filePath, context) + .filter((f) => f.start <= n.startLine && f.end >= n.startLine) + .sort((a, b) => a.start - b.start) + .flatMap((f) => f.path); + const effective = prefix.length > 0 ? `${prefix.join('::')}::${n.qualifiedName}` : alias ? n.qualifiedName : ''; + if (effective !== target) continue; + // One of an overload set is as good as another; a declaration outside the tests over one in them. + if (!best || (isTestPath(best.filePath) && !isTestPath(n.filePath))) best = n; + } + return best ? { original: ref, targetNodeId: best.id, confidence: 0.8, resolvedBy: 'qualified-name' } : null; +}