Files
codegraph/src/resolution/cpp-macro-visibility.ts
T
Colby MchenryandClaude Opus 5.5 98127e54d0 fix(c): a header's definition for a flag nothing sets, like lua-cjson's #ifdef USE_INTERNAL_FPCONV fpconv_init, gives way to the source file the build compiles (#2615)
lua-cjson's fpconv.h defines `static inline void fpconv_init()` only under
`#ifdef USE_INTERNAL_FPCONV` and otherwise declares it `extern` for fpconv.c
to define. Nothing in redis's tree sets the flag, so its build calls
fpconv.c's, but #2613's rule (the file's own or an included header's
definition first) took the header's.

c-feature-variants.ts decides whether a line is compiled only by a build
that sets a flag nothing sets. It reads the conditionals around the line
(cpp-macro-visibility's directiveLines, now exported): `#ifdef`/`#ifndef`,
and `#if` with defined, !, &&, ||, parentheses, integers and bare flags,
then `#elif` and `#else`. A flag is set nowhere when the line's own file
doesn't define it, no build file from the project root down to the
definition or the call names it, and no C/C++ file of their components
(the tree under the nearest directory with a build file) defines it.
Reserved and platform names stay unknown.

cppFunctionOverload's C branch then takes a non-static, non-variant
definition outside the file's tier when one exists, when every definition
the file sees is such a variant, and when the call itself is compiled by
the default build. A branch around both the definition and the call decides
nothing: redis's quicklist.c tests call their own `static mstime` inside
`#ifdef REDIS_TEST`.

A/B against main dc4ccf6b: leveldb, fmt, rocksdb and protobuf identical;
redis 1 call retargeted, lua_cjson.c's fpconv_init() from fpconv.h to
fpconv.c. Indexing time within noise (redis 10.5-11.9 s on main, 10.2-12.3 s
here).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 05:47:57 +00:00

380 lines
16 KiB
TypeScript

/**
* Is a C/C++ call's name a function-like macro visible at the call site?
*
* `TRACE_POINT(1)` parses as a call_expression, so extraction records a
* `calls` ref named `TRACE_POINT`. If the translation unit defines
* `#define TRACE_POINT(value) ...` (in the file itself or through an
* include), the "call" is a macro expansion — and binding it to a
* same-spelled free function in an unrelated file fabricates a caller and a
* callee that never existed (#1838). The resolver asks this before trying
* any strategy and drops such a ref.
*
* Directive summaries are cached per file, but evaluated in translation-unit
* order on every inclusion. Only #pragma once and actual guard state suppress
* reinclusion; an active recursion stack breaks include cycles. Unknown build
* flags remain possible, so only definite macro visibility suppresses a call.
* Object-like definitions participate in conditions but never enter the cached
* call-site timelines (vendor headers can contain tens of thousands of them).
*
* A guard the walk cannot decide (`#define X_H 1`, or any header reached under
* an unknown `#if`) re-enters its header on every inclusion path, which is
* exponential in the include graph's depth (#2127). A re-entry that provably
* repeats an earlier visit which changed nothing is skipped, and a hard budget
* bounds whatever is left: past it, nothing is known and nothing is suppressed.
*/
import * as path from 'path';
import { maskCppRawStrings } from '../extraction/languages/c-cpp';
import { CPP_DEFINE_SIGNATURE, type ResolutionContext, type UnresolvedRef } from './types';
import { resolveImportPath } from './import-resolver';
/** Three-valued: true / false / undefined = depends on an unknown build flag. */
type Truth = boolean | undefined;
type FileEvent =
| { kind: 'define' | 'undef'; name: string; line: number; functionLike: boolean; value: string; wrapsItself: boolean }
| { kind: 'include'; quote: string; spec: string; line: number }
| { kind: 'branch'; op: string; expression: string; guard: boolean; line: number }
| { kind: 'once'; line: number };
type Event = { line: number; defined: Truth };
/** Macro name → events in root-file line order; from `cutoff` on, nothing is known. */
type Timeline = { events: Map<string, Event[]>; cutoff: number };
type Cache = {
summaries: Map<string, FileEvent[]>;
includes: Map<string, string | null>;
/** Indexed files by basename, for `#include "dir/name.h"` that no include root explains. */
byBasename: Map<string, string[]> | null;
/** Per root file: macro name → define/undef events in root-file line order. */
roots: Map<string, Timeline>;
};
const memo = new WeakMap<ResolutionContext, Cache>();
const ROOT_TIMELINE_CAP = 32;
/** Directive events one translation-unit walk may evaluate (#2127). */
const WALK_EVENT_BUDGET = 1_000_000;
const NO_CUTS: string[] = [];
const and = (a: Truth, b: Truth): Truth =>
a === false || b === false ? false : a === true && b === true ? true : undefined;
const or = (a: Truth, b: Truth): Truth =>
a === true || b === true ? true : a === false && b === false ? false : undefined;
const not = (a: Truth): Truth => (a === undefined ? undefined : !a);
export function clearCppMacroVisibility(context: ResolutionContext): void {
memo.delete(context);
}
const isDefine = (n: { kind: string; signature?: string }): boolean =>
n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? '');
/**
* Is this C/C++ `calls` ref a macro expansion rather than a call? True when
* the index knows the name as a function-like macro (extraction mints a
* `constant` per `preproc_function_def`) and either nothing but macros bears
* the name — there is no function to call, and a case-insensitive fuzzy
* match (`SWAP` → `swap`) must not invent one — or the macro is definitely
* visible at the call site.
*/
export function isVisibleCppMacro(ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.language !== 'c' && ref.language !== 'cpp') return false;
if (ref.referenceKind !== 'calls' || !/^\w+$/.test(ref.referenceName)) return false;
// The name cache is the index's truth, invalidated with the resolver's other
// caches; a per-context name set would go stale between indexing batches.
const sameName = context.getNodesByName(ref.referenceName);
if (!sameName.some(isDefine)) return false;
if (!sameName.some((n) => !isDefine(n))) return true;
const cache = cacheFor(context);
const rootKey = `${ref.language}\0${ref.filePath}`;
let timeline = cache.roots.get(rootKey);
if (!timeline) {
timeline = walkTranslationUnit(ref.filePath, ref.language, context, cache);
if (cache.roots.size >= ROOT_TIMELINE_CAP) cache.roots.delete(cache.roots.keys().next().value!);
cache.roots.set(rootKey, timeline);
}
if (ref.line >= timeline.cutoff) return false;
const before = (timeline.events.get(ref.referenceName) ?? []).filter((e) => e.line <= ref.line);
return before.length > 0 && before[before.length - 1]!.defined === true;
}
function cacheFor(context: ResolutionContext): Cache {
let cache = memo.get(context);
if (!cache) {
cache = { summaries: new Map(), includes: new Map(), byBasename: null, roots: new Map() };
memo.set(context, cache);
}
return cache;
}
/** Cache syntax, never conditional truth: an included file can change its flags. */
function summarize(file: string, context: ResolutionContext, cache: Cache): FileEvent[] {
const cached = cache.summaries.get(file);
if (cached) return cached;
const events: FileEvent[] = [];
const lines = directiveLines(context.readFile(file) ?? '');
for (let i = 0; i < lines.length; i++) {
const text = lines[i]!;
const branch = text.match(/^\s*#\s*(ifdef|ifndef|if|elif|else|endif)\b(.*)$/);
if (branch) {
events.push({ kind: 'branch', op: branch[1]!, expression: branch[2]!,
guard: guardsItself(lines, i, branch[1]!, branch[2]!), line: i + 1 });
continue;
}
const directive = text.match(/^\s*#\s*(define|undef)\s+(\w+)(\(?)/);
if (directive) {
events.push({ kind: directive[1] as 'define' | 'undef', name: directive[2]!, line: i + 1,
functionLike: directive[3] === '(', value: text.slice(directive[0].length),
wrapsItself: directive[3] === '(' && callsItself(lines, i, directive[2]!) });
continue;
}
const include = text.match(/^\s*#\s*include\s*([<"])([^>"]+)[>"]/);
if (include) events.push({ kind: 'include', quote: include[1]!, spec: include[2]!, line: i + 1 });
if (/^\s*#\s*pragma\s+once\b/.test(text)) events.push({ kind: 'once', line: i + 1 });
}
cache.summaries.set(file, events);
return events;
}
/**
* The file's lines with comments removed — only as far as the preprocessor
* needs: a line inside a block comment is blank, a directive line loses its
* trailing `//` / `/* … *\/`, and every other line is kept verbatim (its
* content is never read, only whether a block comment opens on it). The
* generic comment stripper is a regex pass over the whole file and this walk
* touches every header of a translation unit, vendor trees included.
*/
export function directiveLines(source: string): string[] {
const out: string[] = [];
let inBlock = false;
for (const raw of maskCppRawStrings(source).source.split(/\r?\n/)) {
let text = raw;
if (inBlock) {
const end = text.indexOf('*/');
if (end < 0) {
out.push('');
continue;
}
text = text.slice(end + 2);
inBlock = false;
}
const directive = /^\s*#/.test(text);
let kept = '';
let quote = '';
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quote) {
if (c === '\\') i++;
else if (c === quote) quote = '';
continue;
}
if (c === '"' || c === "'") {
quote = c;
continue;
}
if (c === '/' && text[i + 1] === '/') {
kept = text.slice(0, i);
break;
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2);
if (end < 0) {
inBlock = true;
kept = text.slice(0, i);
break;
}
text = text.slice(0, i) + ' ' + text.slice(end + 2);
i--;
continue;
}
}
out.push(directive ? kept || text : raw);
}
return out;
}
/** Does the body of the `#define NAME(` at `index` (continuation lines included) call `NAME`? */
function callsItself(lines: string[], index: number, name: string): boolean {
let text = lines[index]!;
for (let j = index; /\\\s*$/.test(lines[j]!) && j + 1 < lines.length; j++) text += ' ' + lines[j + 1]!;
const body = text.slice(text.indexOf('(') + 1);
return new RegExp(`(?:\\b${name}\\s*\\(|\\(\\s*${name}\\s*\\)\\s*\\()`).test(body);
}
/**
* The include-guard idiom: `#ifndef X_H` (or `#if !defined(X_H)`) whose next
* directive is `#define X_H`. Nothing in the file defines the guard before
* the test, so this is the first inclusion and the guarded body is active.
* The same shape around a fallback function-like macro (`#ifndef MIN` /
* `#define MIN(a, b) …`) is read the same way. A default VALUE
* (`#ifndef ENABLE_X` / `#define ENABLE_X 0`) is not: that is the flag a
* build overrides on the command line, so it stays unknown.
*/
function guardsItself(lines: string[], index: number, op: string, expression: string): boolean {
const name =
op === 'ifndef'
? expression.trim()
: expression.match(/^\s*!\s*defined\s*(?:\(\s*(\w+)\s*\)|(\w+))\s*$/)?.slice(1).find(Boolean);
if (!name || !/^\w+$/.test(name)) return false;
for (let j = index + 1; j < lines.length; j++) {
const text = lines[j]!;
if (!/^\s*#/.test(text)) continue;
return new RegExp(`^\\s*#\\s*define\\s+${name}(?:\\s*$|\\()`).test(text);
}
return false;
}
function resolveInclude(
file: string,
quote: string,
spec: string,
language: UnresolvedRef['language'],
context: ResolutionContext,
cache: Cache
): string | null {
const key = `${language}\0${file}\0${quote}${spec}`;
const cached = cache.includes.get(key);
if (cached !== undefined) return cached;
const local = path.posix.normalize(path.posix.join(path.posix.dirname(file), spec.replace(/\\/g, '/')));
let target =
quote === '"' && !local.startsWith('../') && !path.posix.isAbsolute(local) && context.fileExists(local)
? local
: resolveImportPath(spec, file, language, context);
if (!target) {
if (!cache.byBasename) {
cache.byBasename = new Map();
for (const f of context.getAllFiles()) {
const base = f.slice(f.lastIndexOf('/') + 1);
const list = cache.byBasename.get(base);
if (list) list.push(f);
else cache.byBasename.set(base, [f]);
}
}
const normalized = spec.replace(/\\/g, '/');
const matches = (cache.byBasename.get(normalized.slice(normalized.lastIndexOf('/') + 1)) ?? []).filter(
(f) => f === normalized || f.endsWith('/' + normalized)
);
if (matches.length === 1) target = matches[0]!;
}
cache.includes.set(key, target);
return target;
}
function walkTranslationUnit(
rootFile: string,
language: UnresolvedRef['language'],
context: ResolutionContext,
cache: Cache
): Timeline {
const timeline = new Map<string, Event[]>();
const definitions = new Map<string, { defined: Truth; value: Truth; macro: Truth }>();
const scanning = new Set<string>();
const macroNames = new Set<string>();
const once = new Map<string, Truth>();
// Every change a later directive could observe (definitions, `#pragma once`,
// the macro-name set) bumps `version`. A visit that left it unchanged, entered
// again with the same inherited truth while it is still unchanged, starts from
// the same state, so it would take the same branches and change nothing again;
// the events it would push repeat each name's current state, which is already
// its last event. Include cycles cut by the recursion stack (`cuts`) must still
// be cut for the repeat to hold.
let version = 0;
const cuts: string[] = [];
const visits = new Map<string, { inherited: Truth; start: number; end: number; cuts: string[] }>();
let budget = WALK_EVENT_BUDGET;
let cutoff = Infinity;
const condition = (expression: string): Truth => {
const text = expression.trim();
if (/^(?:0x[\da-f]+|\d+)[uUlL]*$/i.test(text)) return Number(text.replace(/[uUlL]+$/, '')) !== 0;
const def = text.match(/^(!)?\s*defined\s*(?:\(\s*(\w+)\s*\)|(\w+))$/);
if (def) {
const known = definitions.get(def[2] ?? def[3]!)?.defined;
return def[1] ? not(known) : known;
}
return /^\w+$/.test(text) ? definitions.get(text)?.value : undefined;
};
const scan = (file: string, inherited: Truth, includeLine?: number): void => {
if (inherited === false || once.get(file) === true || cutoff !== Infinity) return;
if (scanning.has(file)) {
cuts.push(file);
return;
}
const seen = visits.get(file);
if (
seen && seen.inherited === inherited && seen.start === seen.end && seen.end === version &&
seen.cuts.every((c) => scanning.has(c))
) {
cuts.push(...seen.cuts);
return;
}
const start = version;
const firstCut = cuts.length;
scanning.add(file);
let active: Truth = inherited;
const frames: Array<{ parent: Truth; taken: Truth }> = [];
for (const ev of summarize(file, context, cache)) {
const line = includeLine ?? ev.line;
if (cutoff === Infinity && --budget < 0) cutoff = line;
if (cutoff !== Infinity) break;
if (ev.kind === 'branch') {
if (ev.op === 'if' || ev.op === 'ifdef' || ev.op === 'ifndef') {
const known = definitions.get(ev.expression.trim())?.defined;
let selected = ev.op === 'if' ? condition(ev.expression) : ev.op === 'ifndef' ? not(known) : known;
if (selected === undefined && ev.guard) selected = true;
frames.push({ parent: active, taken: selected });
active = and(active, selected);
} else if (ev.op === 'endif') {
active = frames.pop()?.parent ?? inherited;
} else {
const frame = frames[frames.length - 1];
if (frame) {
const test = ev.op === 'else' ? true : condition(ev.expression);
active = and(frame.parent, and(not(frame.taken), test));
frame.taken = or(frame.taken, test);
}
}
continue;
}
if (active === false) continue;
if (ev.kind === 'once') {
const next = or(once.get(file) ?? false, active);
if (next !== (once.get(file) ?? false)) version++;
once.set(file, next);
continue;
}
if (ev.kind === 'include') {
const target = resolveInclude(file, ev.quote, ev.spec, language, context, cache);
if (target) scan(target, active, line);
continue;
}
const prior = definitions.get(ev.name);
const defining = ev.kind === 'define';
const macro = defining && ev.functionLike && !ev.wrapsItself;
const now = active === true ? macro : prior?.macro === macro ? macro : undefined;
// A name no directive has touched is unknown, not undefined: the build
// can set it on the command line. So an `#undef` under an undecidable
// `#if` leaves it unknown (#2069); only a certain one clears it.
const next = {
defined: defining ? or(prior?.defined, active) : and(prior?.defined, not(active)),
value: defining && active === true ? condition(ev.value) : undefined,
macro: now,
};
if (next.defined !== prior?.defined || next.value !== prior?.value || next.macro !== prior?.macro) version++;
definitions.set(ev.name, next);
if (ev.functionLike && !macroNames.has(ev.name)) {
macroNames.add(ev.name);
version++;
}
if (macroNames.has(ev.name)) {
const events = timeline.get(ev.name) ?? [];
events.push({ line, defined: now });
timeline.set(ev.name, events);
}
}
scanning.delete(file);
const own = cuts.length > firstCut ? [...new Set(cuts.splice(firstCut))] : NO_CUTS;
cuts.push(...own);
visits.set(file, { inherited, start, end: version, cuts: own });
};
scan(rootFile, true);
return { events: timeline, cutoff };
}