mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 22:35:47 +08:00
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>
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.
|
|
*/
|
|
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 };
|
|
}
|