diff --git a/CHANGELOG.md b/CHANGELOG.md index 10624a0f3f..6968a89507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes carried by this fork +- C functions with conditional signatures and a shared body are now indexed, and conditional control statements no longer appear as functions or take calls away from their enclosing function. + - A TypeScript or JavaScript function wrapped in a string-named wrapper whose result nothing binds — `Runtime.handler(cmd, Effect.fn("cli.api")(fn))`, an array element, an arrow body, a return value — is now indexed under the diff --git a/__tests__/c-conditional-function-headers.test.ts b/__tests__/c-conditional-function-headers.test.ts new file mode 100644 index 0000000000..804eb1c448 --- /dev/null +++ b/__tests__/c-conditional-function-headers.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { extractFromSource } from '../src/extraction'; +import { resetKernelForTests, tryKernelExtract } from '../src/extraction/kernel'; +import { blankCAlternativeFunctionHeaders } from '../src/extraction/languages/c-cpp'; +import type { ExtractionResult } from '../src/types'; + +const kernelAvailable = fs.existsSync(path.join(__dirname, '..', 'codegraph-kernel', 'prebuilds', + `${process.platform}-${process.arch}`, 'codegraph-kernel.node')) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; +const envKeys = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS', 'CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT'] as const; + +describe('C conditional function headers and recovered control statements', () => { + let savedEnv: Record; + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['c']); + }); + beforeEach(() => { + savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + resetKernelForTests(); + }); + afterEach(() => { + for (const key of envKeys) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + resetKernelForTests(); + }); + + function extract(backend: 'wasm' | 'kernel', source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource('qp.c', source, 'c'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + process.env.CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT = '1'; + const result = tryKernelExtract('qp.c', source, 'c'); + expect(result).not.toBeNull(); + return result!; + } + + function expectCall(result: ExtractionResult, caller: string, callee: string) { + const owner = result.nodes.find(n => n.kind === 'function' && n.name === caller)!; + expect(owner).toBeDefined(); + expect(result.unresolvedReferences.some(ref => ref.fromNodeId === owner.id + && ref.referenceKind === 'calls' && ref.referenceName === callee)).toBe(true); + } + + const backends = kernelAvailable ? ['wasm', 'kernel'] as const : ['wasm'] as const; + for (const crlf of [false, true]) { + const eol = (text: string) => crlf ? text.replace(/\n/g, '\r\n') : text; + const label = crlf ? 'CRLF' : 'LF'; + + it.each(backends)(`extracts shared bodies once, with original locations and calls (${label}, %s)`, backend => { + const source = eol(`#ifndef Q_SPY +bool QActive_post_(QActive * const me, QEvt const * const e, + uint_fast16_t const margin) +#else +bool QActive_post_(QActive * const me, QEvt const * const e, + uint_fast16_t const margin, void const * const sender) +#endif +{ + QF_CRIT_STAT_ + Q_REQUIRE_ID(100, e != (QEvt const *)0); + deliver(e); + return true; +} +void after(void) { QActive_post_(0, 0, 0); } +`); + const result = extract(backend, source); + expect(result.nodes.filter(n => n.kind === 'function').map(n => n.name)) + .toEqual(['QActive_post_', 'after']); + const post = result.nodes.find(n => n.name === 'QActive_post_')!; + expect([post.startLine, post.endLine]).toEqual([2, 13]); + expectCall(result, 'QActive_post_', 'deliver'); + expectCall(result, 'QActive_post_', 'Q_REQUIRE_ID'); + expectCall(result, 'after', 'QActive_post_'); + const normalized = blankCAlternativeFunctionHeaders(source); + expect(normalized.length).toBe(source.length); + expect([...normalized.matchAll(/[\r\n]/g)].map(m => m.index)) + .toEqual([...source.matchAll(/[\r\n]/g)].map(m => m.index)); + expect(normalized.indexOf('deliver')).toBe(source.indexOf('deliver')); + }); + + it.each(backends)(`keeps conditional else-if calls in the real caller (${label}, %s)`, backend => { + const result = extract(backend, eol(`void dispatch(int r) { + if (r == 0) { first(); } +#ifdef Q_SPY + /* tracing-only branches */ + else if (r == 1) { + handled(); + } + else if (r == 2) { + ignored(); + } +#endif + else { last(); } + after(); +} +void Q_REQUIRE_ID(int id) { legitimate(); } +`)); + expect(result.nodes.filter(n => n.kind === 'function').map(n => n.name)) + .toEqual(['dispatch', 'Q_REQUIRE_ID']); + for (const callee of ['first', 'handled', 'ignored', 'last', 'after']) { + expectCall(result, 'dispatch', callee); + } + expectCall(result, 'Q_REQUIRE_ID', 'legitimate'); + }); + + it.each(backends)(`supports elif and function-pointer parameters (${label}, %s)`, backend => { + const result = extract(backend, eol(`#if SMALL +static char *pick(void (*cb)(int)) +#elif MEDIUM +static char *pick(void (*cb)(int), int count) +#else +static char *pick(void (*cb)(int), int count, void *sender) +#endif +/* common body */ +{ return lookup(); } +`)); + expect(result.nodes.filter(n => n.kind === 'function').map(n => n.name)).toEqual(['pick']); + expectCall(result, 'pick', 'lookup'); + }); + } + + it.each(backends)('resolves calls to recovered functions in an indexed project (%s)', async backend => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else { + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + process.env.CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT = '1'; + } + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-c-conditional-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync(path.join(root, 'post.c'), `#ifndef Q_SPY +int post(int x) +#else +int post(int x, void *sender) +#endif +{ + if (x == 0) { first(); } +#ifdef Q_SPY + else if (x == 1) { handled(); } +#endif + return x; +} +`); + fs.writeFileSync(path.join(root, 'app.c'), `void first(void) {} +void handled(void) {} +void app(void) { post(0); } +`); + cg = await CodeGraph.init(root, { index: true, silent: true }); + const nodes = [...cg.getNodesInFile('post.c'), ...cg.getNodesInFile('app.c')]; + const byId = new Map(nodes.map(n => [n.id, n.name])); + const calls = cg.getOutgoingEdgesFrom(nodes.map(n => n.id)).filter(e => e.kind === 'calls') + .map(e => `${byId.get(e.source)} -> ${byId.get(e.target)}`); + expect(nodes.filter(n => n.kind === 'function').map(n => n.name).sort()) + .toEqual(['app', 'first', 'handled', 'post']); + expect(calls.sort()).toEqual(['app -> post', 'post -> first', 'post -> handled']); + } finally { + cg?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + '#if A\nvoid f(void) {}\n#else\nvoid f(int x) {}\n#endif\n', + '#if A\nvoid f(void)\n#else\nvoid g(void)\n#endif\n{}\n', + '#if A\nvoid f(void)\n#endif\n{}\n', + '#if A\nvoid f(\n#else\nvoid f(int x\n#endif\n) {}\n', + '#if A\n#if B\nvoid f(void)\n#endif\n#else\nvoid f(int x)\n#endif\n{}\n', + '#if A\n#define TYPE int\nTYPE f(void)\n#else\nint f(int x)\n#endif\n{}\n', + '/*\n#if A\nvoid f(void)\n#else\nvoid f(int x)\n#endif\n{}\n*/\n', + '#define HEADERS \\\n#if A \\\nvoid f(void) \\\n#else \\\nvoid f(int x) \\\n#endif\n{}\n', + ])('leaves unrelated or ambiguous conditionals unchanged: %s', source => { + expect(blankCAlternativeFunctionHeaders(source)).toBe(source); + }); +}); diff --git a/codegraph-kernel/src/ccpp/mod.rs b/codegraph-kernel/src/ccpp/mod.rs index 899451129e..d215504db4 100644 --- a/codegraph-kernel/src/ccpp/mod.rs +++ b/codegraph-kernel/src/ccpp/mod.rs @@ -1035,17 +1035,18 @@ impl<'t> Walker<'t> { } - /// cppExtractor.isMisparsedFunction (languages/c-cpp.ts:811). cpp only. + /// C/C++ isMisparsedFunction: keep recovered control-statement bodies in + /// their enclosing caller rather than creating functions named `if`. fn is_misparsed_function(&self, name: &str, node: Node) -> bool { + if matches!(name, "switch" | "if" | "for" | "while" | "do" | "case" | "return") { + return true; + } if self.variant != Variant::Cpp { return false; } if name.starts_with("namespace") { return true; } - if matches!(name, "switch" | "if" | "for" | "while" | "do" | "case" | "return") { - return true; - } is_macro_misparsed_type_decl(node) } diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 4106a953c2..9661fc7539 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -404,6 +404,10 @@ function extractCppReturnType(node: SyntaxNode, source: string): string | undefi } export const cExtractor: LanguageExtractor = { + // Error recovery can read `else if (...) { ... }` across a preprocessor + // boundary as a function with return type `else`. Keep its body's calls + // in the enclosing function through the existing misparse-body walker. + isMisparsedFunction: (name) => ['switch', 'if', 'for', 'while', 'do', 'case', 'return'].includes(name), resolveName: (node, source) => recoverSingleArgMacroDefinedName(node, source) ?? unnamedTypeVariableName(node, source), // A struct, union or enum defined in a declaration's type is documented by @@ -2395,9 +2399,74 @@ export function blankCDesignatedMacroArgs(source: string): string { return rawStrings.restore(out.join('')); } +/** + * Alternative C function signatures can share a body after #endif (QP's + * tracing-enabled signatures add a sender parameter). Neither signature is + * a definition to tree-sitter in that shape. Keep the first signature only + * when EVERY branch is a complete header for the same name, with an #else + * and a shared opening brace. This chooses a representative signature, not + * a build configuration. Complete definitions and nested/partial headers + * stay untouched. All offsets, including CRLF, remain unchanged. + */ +export function blankCAlternativeFunctionHeaders(source: string): string { + if (!source.includes('#')) return source; + const code = maskCppCommentsAndLiterals(source); + type Region = { start: number; branch: number; headers: [number, number][]; valid: boolean; hasElse: boolean }; + const stack: Region[] = []; + const spans: [number, number][] = []; + const headerName = (header: string): string | undefined => { + const match = /^\s*(?:[A-Za-z_]\w*[\s*]+)+([A-Za-z_]\w*)\s*\(/.exec(header); + if (!match || /[;{}#=]/.test(header)) return undefined; + let depth = 1; + for (let i = match[0].length; i < header.length; i++) { + if (header[i] === '(') depth++; + else if (header[i] === ')' && --depth === 0) { + return header.slice(i + 1).trim() === '' ? match[1] : undefined; + } + } + return undefined; + }; + // Consume continued directives together: a #if spelled inside a macro + // replacement must never open a region of its own. + const directives = /^[ \t]*#(?:\\\r?\n|[^\r\n])*/gm; + for (const match of code.matchAll(directives)) { + const start = match.index!; + const end = start + match[0].length; + const directive = /^[ \t]*#[ \t]*(\w+)/.exec(match[0])?.[1]; + const current = stack[stack.length - 1]; + if (directive === 'if' || directive === 'ifdef' || directive === 'ifndef') { + if (current) current.valid = false; + stack.push({ start, branch: end, headers: [], valid: true, hasElse: false }); + } else if (current && (directive === 'else' || directive === 'elif')) { + if (current.hasElse) current.valid = false; + current.headers.push([current.branch, start]); + current.branch = end; + if (directive === 'else') current.hasElse = true; + } else if (current && directive === 'endif') { + stack.pop(); + current.headers.push([current.branch, start]); + if (!current.valid || !current.hasElse || !/^\s*\{/.test(code.slice(end))) continue; + const names = current.headers.map(([from, to]) => headerName(code.slice(from, to))); + if (!names[0] || names.some((name) => name !== names[0])) continue; + const [firstStart, firstEnd] = current.headers[0]!; + spans.push([current.start, firstStart], [firstEnd, end]); + } else if (current) { + current.valid = false; + } + } + if (spans.length === 0) return source; + const out = source.split(''); + for (const [from, to] of spans) { + for (let i = from; i < to; i++) if (out[i] !== '\n' && out[i] !== '\r') out[i] = ' '; + } + return out.join(''); +} + function preParseCSource(source: string): string { const rawStrings = maskCppRawStrings(source); - source = rawStrings.source; + // Run before the directive snapshot so restoreDirectiveLines does not + // reintroduce the conditional that split the function header from its body. + source = blankCAlternativeFunctionHeaders(rawStrings.source); const inner = blankCDesignatedMacroArgs(blankCKernelAnnotations(blankCCplusplusGuardBodies(source))); let blanked = blankCLeadingAttrMacros( blankLoneMacroLines(