mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 08:47:04 +08:00
2ff7a99c0e
* fix(cpp): bound the macro-visibility include walk (#2127)
isVisibleCppMacro walks the call site's translation unit to learn whether a
function-like macro is visible there (#1838). A header whose guard the walk
cannot decide -- `#define X_H 1` (the OpenSceneGraph / osgEarth idiom, which
guardsItself does not read as a guard), or any header reached under an
unknown `#if`, where neither its guard nor `#pragma once` settles -- was re-scanned on
every inclusion path, and every re-scan pushed its define events onto the
cached timeline again. On a layered include graph that is 2^depth scans and
2^depth timeline entries: "Resolving refs" died with "Ineffective
mark-compacts near heap limit" at the first ref whose file reached such a
tree, at the same ref whatever the heap size.
The walk now keeps a version that every observable change bumps
(definitions, `#pragma once`, the macro-name set). A re-entry with the same
inherited truth, while the version still equals that of an earlier visit
which itself changed nothing, is skipped: it would start from the same state,
take the same branches and change nothing again, and the events it would push
repeat each macro's current state, already its last event. Include cycles cut
by the recursion stack are recorded per visit and must still be cut for a
skip. A hard budget of 1M directive events per walk bounds what is left;
past it the timeline is unknown and nothing at or after the cut line is
suppressed, so the budget can only fall back to ordinary resolution, never
invent a macro.
Answers are unchanged: 20,000 random include graphs (guards bare / valued /
pragma once / under flags, #if/#elif/#else, #undef, cycles) give identical
isVisibleCppMacro results to the previous walker on 1.25M queries, with
about one re-entry in eight skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 73db32f81a)
* docs(changelog): credit the C/C++ macro walk bound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
16 KiB
TypeScript
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.
|
|
*/
|
|
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 };
|
|
}
|