Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- **A C `#if` group whose branches are not whole statements no longer produces phantom functions.** An `else if (…) { … }` arm kept behind `#ifdef`, an `if (…)` header whose body sits after the `#endif` (the ST HAL's per-device latency tables), or a function signature that differs per configuration all read to the C grammar as a function *named* `if` — and every real function after it in the file was then filed underneath it, or dropped. Such a group is now collapsed to its first live branch before parsing, offsets kept, and block macros written in capitals (`ATOMIC_BLOCK(…) { … }`) are recognized like their lowercase cousins. On a betaflight tree 265 phantom nested functions became 5, and whole functions that had been missing (`spiInternalStartDMA`, `processSmartPortTelemetry`, the CMSIS matrix routines) are back with their callers at exact-match confidence. Re-index after upgrading.
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
- In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588)
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
Expand Down
171 changes: 171 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13796,6 +13796,177 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => {
}
});

it('blankCUnbalancedConditionalBranches collapses a damaged #if group to its first branch, offsets kept', async () => {
const { blankCUnbalancedConditionalBranches } = await import('../src/extraction/languages/c-cpp');
const src = [
'void f(int id)',
'{',
' if (id == 1) {',
' a(id);',
' }',
'#if defined(USE_V) || \\',
' defined(USE_W)',
' else if (id == 2) { // "{" in a comment',
' b(id);',
' }',
'#else',
' else if (id == 3) {',
' c(id);',
' }',
'#endif',
'#ifdef USE_X',
' d(id);',
'#else',
' e(id);',
'#endif',
'}',
'#if 0',
'void dead(void) {',
'#else',
'void live(void) {',
'#endif',
'}',
].join('\n');
const out = blankCUnbalancedConditionalBranches(src);
expect(out.length).toBe(src.length);
expect(out.split('\n').length).toBe(src.split('\n').length);
const lines = out.split('\n');
// The `else`-led group opens on a feature test and has an `#else`: the
// `#else` branch (the build without the feature) is verbatim; directive
// lines (continuation included) and the feature branch are spaces.
expect(lines[5]).toBe(' '.repeat(src.split('\n')[5]!.length));
expect(lines[6]).toBe(' '.repeat(src.split('\n')[6]!.length));
expect(lines[7]).toBe(' '.repeat(src.split('\n')[7]!.length));
expect(out).not.toContain('b(id)');
expect(lines[10]).toBe(' ');
expect(lines[11]).toBe(' else if (id == 3) {');
expect(lines[12]).toBe(' c(id);');
expect(lines[14]).toBe(' ');
// A balanced group is untouched, directives included.
expect(lines[15]).toBe('#ifdef USE_X');
expect(lines[16]).toBe(' d(id);');
expect(lines[18]).toBe(' e(id);');
expect(lines[19]).toBe('#endif');
// `#if 0` keeps the live branch instead.
expect(out).not.toContain('dead');
expect(lines[24]).toBe('void live(void) {');
// No conditional group at all: identity.
const plain = 'int g(void) {\n#define X 1\n return X;\n}\n';
expect(blankCUnbalancedConditionalBranches(plain)).toBe(plain);
});

it('blankCUnbalancedConditionalBranches: two function heads under #ifdef/#else keep the #else head; expression fragments keep their only branch', async () => {
const { blankCUnbalancedConditionalBranches } = await import('../src/extraction/languages/c-cpp');
// jq's main.c: `umain` for WIN32, `main` otherwise — `main` must survive.
const heads = [
'#ifdef WIN32',
'int umain(int argc, char* argv[]) {',
'#else /*}*/',
'int main(int argc, char* argv[]) {',
'#endif',
' return run(argc, argv);',
'}',
].join('\n');
const h = blankCUnbalancedConditionalBranches(heads).split('\n');
expect(h[1]).toBe(' '.repeat('int umain(int argc, char* argv[]) {'.length));
expect(h[3]).toBe('int main(int argc, char* argv[]) {');
expect(h[0]).toBe(' ');
// `#ifndef` is the inverse: its first branch is the default build.
const inv = heads.replace('#ifdef WIN32', '#ifndef POSIX');
expect(blankCUnbalancedConditionalBranches(inv).split('\n')[1]).toBe('int umain(int argc, char* argv[]) {');
// A branch that is a fragment of an `if (…)` condition (STM32 HAL), or
// that opens with `} else if` (betaflight cli.c): directives blanked, the
// only branch kept, so the statement parses as written for that build.
const frag = [
'int check(op_t *h1, op_t *h4)',
'{',
' if ((h1 == NULL)',
'#if defined(STM32G474xx)',
' || (h4 == NULL)',
'#endif',
' )',
' {',
' return 1;',
' }',
' if (',
'#if !defined(USE_FLASH)',
' isEmpty(h1) ||',
'#endif',
' strncasecmp(h1, "rom", 3) == 0) {',
' reboot(1);',
'#if defined(USE_FLASH)',
' } else if (isEmpty(h1)) {',
' reboot(2);',
'#endif',
' } else {',
' reboot(0);',
' }',
' return 0;',
'}',
].join('\n');
const f = blankCUnbalancedConditionalBranches(frag).split('\n');
expect(f[3]).toBe(' '.repeat('#if defined(STM32G474xx)'.length));
expect(f[4]).toBe(' || (h4 == NULL)');
expect(f[5]).toBe(' ');
expect(f[12]).toBe(' isEmpty(h1) ||');
expect(f[17]).toBe(' } else if (isEmpty(h1)) {');
expect(f[16]).toBe(' '.repeat('#if defined(USE_FLASH)'.length));
});

it('a #if branch beginning with `else` no longer files the rest of the file under the enclosing function', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-c-ifelse-'));
try {
fs.writeFileSync(
path.join(dir, 'current.c'),
[
'void currentMeterRead(int id, meter_t *meter)',
'{',
' if (id == 1) {',
' adcRead(meter);',
' }',
'#ifdef USE_VIRTUAL',
' else if (id == 2) {',
' virtualRead(meter);',
' }',
'#endif',
' else {',
' resetMeter(meter);',
' }',
'}',
'',
'static bool markBusy(void)',
'{',
' ATOMIC_BLOCK(NVIC_PRIO_MAX) {',
' busy = true;',
' }',
' return true;',
'}',
'',
'int after(void)',
'{',
' return 1;',
'}',
'',
].join('\n')
);
const cg = await CodeGraph.init(dir, { index: true });
try {
const fns = cg.getNodesByKind('function').filter((n) => n.filePath === 'current.c');
const byName = Object.fromEntries(fns.map((n) => [n.name, n]));
expect(Object.keys(byName).sort()).toEqual(['after', 'currentMeterRead', 'markBusy']);
expect(byName.currentMeterRead!.endLine).toBe(14);
expect(byName.markBusy!.qualifiedName).toBe('markBusy');
expect(byName.markBusy!.endLine).toBe(22);
expect(byName.after!.qualifiedName).toBe('after');
expect(byName.after!.startLine).toBe(24);
} finally {
cg.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('blankCStatementMacroCalls blanks indented iterator macros, keeps the block', async () => {
const { blankCStatementMacroCalls } = await import('../src/extraction/languages/c-cpp');
const src = [
Expand Down
144 changes: 140 additions & 4 deletions src/extraction/languages/c-cpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1921,7 +1921,10 @@ export function blankCStatementMacroCalls(source: string): string {
const content = (l: string): string => l.replace(/\r$/, '').trim();
for (let i = 0; i < lines.length; i++) {
const line = lines[i] as string;
const m = /^[ \t]+([a-z_][a-z0-9_]*)[ \t]*\(/.exec(line);
// Lowercase iterator macros, or an all-caps block macro
// (`ATOMIC_BLOCK(NVIC_PRIO_MAX) {`, `PG_FOREACH(reg) {` — betaflight);
// a PascalCase name is left alone (a constructor, if the file is C++).
const m = /^[ \t]+([a-z_][a-z0-9_]*|[A-Z_][A-Z0-9_]*)[ \t]*\(/.exec(line);
if (!m || C_STMT_MACRO_KEYWORDS.has(m[1] as string)) continue;
const open = line.indexOf('(', m[0].length - 1);
let depth = 0;
Expand Down Expand Up @@ -2544,6 +2547,132 @@ export function blankCDesignatedMacroArgs(source: string): string {
return rawStrings.restore(out.join(''));
}

/**
* Collapse a `#if … #elif … #else … #endif` group whose branches are not
* self-contained statements to its first live branch. tree-sitter-c keeps
* EVERY branch of a conditional group and parses each as a run of block
* items, so a group is only harmless when each branch is complete on its
* own. Three shapes in real C are not, and each ends in a phantom function
* (K&R `if(cond) { … }` reads as an implicit-int function definition named
* `if`, and the extractor then files it — and every symbol after it — under
* the enclosing function; 265 on a betaflight tree, #1729 follow-up):
*
* - a branch that BEGINS with `else` — `} #ifdef X else if (…) { … } #endif`
* (a conditional arm of an if-chain);
* - a branch that ENDS with a bare `if (…)` / `else if (…)` header whose
* body sits after the `#endif` (the ST HAL's per-device latency tables);
* - a branch whose braces do not balance — a function signature or a `{`
* that differs per configuration, with the body shared.
*
* The first branch not written `#if 0` is kept verbatim; every other branch
* AND the group's directive lines are blanked to spaces (newlines and `\r`
* kept, so offsets survive), which is exactly what the preprocessor would
* hand a compiler for that configuration. The symbols the other branches
* held were not extracting cleanly anyway. Balanced groups — the vast
* majority — are untouched. This pass edits directive lines on purpose, so
* it runs AFTER `restoreDirectiveLines` (like the named-variadic pass).
* Groups are handled innermost-first; an inner group inside a blanked branch
* simply disappears with it.
*/
const C_COND_OPEN_RE = /^[ \t]*#[ \t]*(if|ifdef|ifndef)\b[ \t]*(.*)$/;
const C_COND_NEXT_RE = /^[ \t]*#[ \t]*(elif|else)\b/;
const C_COND_ENDIF_RE = /^[ \t]*#[ \t]*endif\b/;
export function blankCUnbalancedConditionalBranches(source: string): string {
if (source.indexOf('#') === -1) return source;
const lines = source.split('\n');
const stripCr = (l: string): string => (l.endsWith('\r') ? l.slice(0, -1) : l);
// Code content of a line: comments and string / char literals removed.
const code = (l: string): string =>
stripCr(l)
.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '')
.replace(/\/\*.*?\*\//g, '')
.replace(/\/\/.*$/, '')
.trim();
const blank = (l: string): string => l.replace(/[^\r]/g, ' ');
// Directive line indices of each open group, innermost last.
// `positive`: the group opens on a feature test (`#ifdef X`, `#if defined(X)`,
// `#if X`), whose `#else` branch is the default build. `elseAt`: index into
// marks of that `#else`, if any.
const stack: Array<{ marks: number[]; dead: boolean; positive: boolean; elseAt: number }> = [];
const directive: boolean[] = [];
let changed = false;
let continuation = false;
for (let i = 0; i < lines.length; i++) {
const line = stripCr(lines[i] as string);
const isDirective: boolean = continuation || /^[ \t]*#/.test(line);
directive[i] = isDirective;
const wasContinuation = continuation;
continuation = isDirective && /\\\s*$/.test(line);
if (!isDirective || wasContinuation) continue;
let m: RegExpExecArray | null;
if ((m = C_COND_OPEN_RE.exec(line))) {
const cond = (m[2] as string).trim();
const dead = m[1] === 'if' && /^0\b/.test(cond);
const positive = m[1] === 'ifdef' || (m[1] === 'if' && !dead && !/^!/.test(cond));
stack.push({ marks: [i], dead, positive, elseAt: -1 });
continue;
}
const top = stack[stack.length - 1];
if (!top) continue;
if (C_COND_NEXT_RE.test(line)) {
if (/^[ \t]*#[ \t]*else\b/.test(line)) top.elseAt = top.marks.length;
top.marks.push(i);
continue;
}
if (!C_COND_ENDIF_RE.test(line)) continue;
stack.pop();
top.marks.push(i);
// Which branch survives: never `#if 0`'s; for a feature test with an
// `#else`, the `#else` branch — the build without the feature is the one
// that defines `main`, not `umain` (jq's `#ifdef WIN32`); otherwise the
// first. A kept branch is verbatim; the others become spaces.
const keep = top.dead ? 1 : top.positive && top.elseAt > 0 ? top.elseAt : 0;
if (keep >= top.marks.length - 1) continue; // `#if 0` with nothing to keep
let damaged = false;
for (let b = 0; b < top.marks.length - 1 && !damaged; b++) {
const body: string[] = [];
for (let k = (top.marks[b] as number) + 1; k < (top.marks[b + 1] as number); k++) {
if (directive[k]) continue; // a `\`-continued directive line
const c = code(lines[k] as string);
if (c) body.push(c);
}
if (body.length === 0) continue;
const first = body[0] as string;
const last = body[body.length - 1] as string;
let depth = 0;
let dips = false;
for (const l of body) {
for (const ch of l) {
depth += ch === '{' ? 1 : ch === '}' ? -1 : 0;
if (depth < 0) dips = true; // closes a block it did not open (`} else if (…) {`)
}
}
damaged =
depth !== 0 ||
dips ||
/^else\b/.test(first) ||
// A fragment of an expression: `|| (h4 == NULL)` inside an `if (…)`,
// `isEmpty(s) ||` before the rest of the condition.
/^(\|\||&&|,|\)|:|\?)/.test(first) ||
/(\|\||&&|,|\(|\?|:)$/.test(last) ||
(/^(else[ \t]+)?if[ \t]*\(/.test(last) && /\)$/.test(last));
}
if (!damaged) continue;
for (let b = 0; b < top.marks.length - 1; b++) {
const from = top.marks[b] as number;
const to = top.marks[b + 1] as number;
// The directive line (with its `\`-continuations), then — unless this
// is the kept branch — the branch body.
for (let k = from; k < to && (k === from || directive[k]); k++) lines[k] = blank(lines[k] as string);
if (b === keep) continue;
for (let k = from + 1; k < to; k++) lines[k] = blank(lines[k] as string);
}
lines[i] = blank(lines[i] as string);
changed = true;
}
return changed ? lines.join('\n') : source;
}

function preParseCSource(source: string): string {
const rawStrings = maskCppRawStrings(source);
source = rewriteFoundationEnumMacros(rawStrings.source);
Expand Down Expand Up @@ -2571,9 +2700,16 @@ function preParseCSource(source: string): string {
)
);
if (looksLikeCudaSource(blanked)) blanked = blankCudaConstructs(blanked);
// The named-variadic `#define` pass runs AFTER the directive restore — it
// deliberately edits directive lines (see its doc comment).
return rawStrings.restore(blankCNamedVariadicDefineDots(restoreDirectiveLines(source, blanked)));
// The named-variadic `#define` pass and the conditional-group collapse run
// AFTER the directive restore — they deliberately edit directive lines (see
// their doc comments) — and BEFORE the raw-string restore: both are
// length-preserving, and the collapse must count braces with raw-string
// bodies still masked.
return rawStrings.restore(
blankCUnbalancedConditionalBranches(
blankCNamedVariadicDefineDots(restoreDirectiveLines(source, blanked))
)
);
}

/** A constructor prototype carries defaults even when its body is in another file. */
Expand Down