Skip to content
Open
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
19 changes: 17 additions & 2 deletions src/graph/extract/c.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export function extractC(

// ─── Pass 1 + 2 ────────────────────────────────────────────────────────────

// Function nodes that so far only come from a prototype. A later definition
// of the same function takes over the node's source_location so anchors hash
// the body rather than the one-line prototype.
const prototypeNodes = new WeakSet<GraphNode>();

export function collectDecls(
node: TSNode,
relativePath: string,
Expand All @@ -65,12 +70,22 @@ export function collectDecls(
if (child.type === "function_definition") {
const name = extractFunctionName(child);
if (name === null) continue;
pushNode(result, declByName, makeNode(relativePath, name, "function", child, true, LANG));
const fnNode = makeNode(relativePath, name, "function", child, true, LANG);
const proto = result.nodes.find((n) => n.id === fnNode.id);
if (proto !== undefined && prototypeNodes.has(proto)) {
// Same node object stays in declByName, so call edges are unaffected.
proto.source_location = fnNode.source_location;
prototypeNodes.delete(proto);
} else {
pushNode(result, declByName, fnNode);
}
} else if (child.type === "declaration") {
// typedef struct / forward declarations for functions
const name = extractDeclName(child);
if (name !== null) {
pushNode(result, declByName, makeNode(relativePath, name, "function", child, true, LANG));
const protoNode = makeNode(relativePath, name, "function", child, true, LANG);
pushNode(result, declByName, protoNode);
if (result.nodes.includes(protoNode)) prototypeNodes.add(protoNode);
}
} else if (
child.type === "struct_specifier" ||
Expand Down
136 changes: 135 additions & 1 deletion tests/shared/graph/c.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractC } from "../../../src/graph/extract/c.js";
import { buildAnchor } from "../../../src/docs/anchors.js";
import type { FileExtraction } from "../../../src/graph/types.js";

describe("C extraction", () => {
it("extracts a function definition", () => {
Expand Down Expand Up @@ -74,3 +79,132 @@ describe("C extraction", () => {
expect(ex.parse_errors).toHaveLength(0);
});
});

describe("C prototypes vs definitions → anchors", () => {
const REL = "src/helper.c";
let root: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "c-proto-anchor-"));
mkdirSync(join(root, "src"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

function extractFile(src: string): FileExtraction {
writeFileSync(join(root, REL), src);
return extractC(readFileSync(join(root, REL), "utf-8"), REL);
}
const fnNodes = (ex: FileExtraction, label: string) =>
ex.nodes.filter(n => n.kind === "function" && n.label === label);
const calls = (ex: FileExtraction) =>
ex.edges.filter(e => e.relation === "calls").map(e => `${e.source} -> ${e.target}`).sort();

const PROTO_THEN_DEF = [
"#include <stdio.h>", // L1
"static int helper(int x);", // L2
"void log_it(int v);", // L3
"int run(int v) {", // L4
" return helper(v) + 1;", // L5
"}", // L6
"static int helper(int x) {", // L7
" log_it(x);", // L8
" return x * 2;", // L9
"}", // L10
"",
].join("\n");

it("prototype followed by definition points the single node at the definition body", () => {
const ex = extractFile(PROTO_THEN_DEF);
const helpers = fnNodes(ex, "helper");
expect(helpers).toHaveLength(1);
expect(helpers[0].id).toBe(`${REL}:helper:function`);
expect(helpers[0].source_location).toBe("L7-10");
// Existing metadata contract: C functions (static included) are exported.
expect(helpers[0].exported).toBe(true);
expect(helpers[0].language).toBe("c");
expect(ex.nodes.filter(n => n.id === helpers[0].id)).toHaveLength(1);

const anchor = buildAnchor(helpers[0], root);
expect(anchor).not.toBeNull();
expect(anchor!.symbol_id).toBe(`${REL}:helper:function`);
});

it("a body edit after the prototype changes the anchor hash", () => {
const before = buildAnchor(fnNodes(extractFile(PROTO_THEN_DEF), "helper")[0], root);
const edited = PROTO_THEN_DEF.replace("return x * 2;", "return x * 3;");
expect(edited).not.toBe(PROTO_THEN_DEF);
const after = buildAnchor(fnNodes(extractFile(edited), "helper")[0], root);
expect(before).not.toBeNull();
expect(after).not.toBeNull();
expect(after!.content_hash).not.toBe(before!.content_hash);
});

it("keeps call edges into and out of a prototyped-then-defined function", () => {
const ex = extractFile(PROTO_THEN_DEF);
expect(calls(ex)).toEqual([
`${REL}:helper:function -> ${REL}:log_it:function`,
`${REL}:run:function -> ${REL}:helper:function`,
]);
});

it("prototype-only function stays a node at the prototype line", () => {
const ex = extractFile(PROTO_THEN_DEF);
const logIt = fnNodes(ex, "log_it");
expect(logIt).toHaveLength(1);
expect(logIt[0].source_location).toBe("L3");
expect(logIt[0].exported).toBe(true);
expect(buildAnchor(logIt[0], root)).not.toBeNull();
});

it("definition before a later prototype keeps the definition span", () => {
const src = [
"int helper(int x) {", // L1
" return x + 1;", // L2
"}", // L3
"int helper(int x);", // L4
"int run(void) { return helper(1); }", // L5
"",
].join("\n");
const ex = extractFile(src);
const helpers = fnNodes(ex, "helper");
expect(helpers).toHaveLength(1);
expect(helpers[0].source_location).toBe("L1-3");
expect(calls(ex)).toEqual([`${REL}:run:function -> ${REL}:helper:function`]);

const before = buildAnchor(helpers[0], root)!;
const after = buildAnchor(
fnNodes(extractFile(src.replace("x + 1", "x + 2")), "helper")[0],
root,
)!;
expect(after.content_hash).not.toBe(before.content_hash);
});

it("prototype, definition, then a second prototype keeps the definition span", () => {
const ex = extractFile([
"int helper(void);", // L1
"int helper(void) { return 1; }", // L2
"int helper(void);", // L3
"",
].join("\n"));
const helpers = fnNodes(ex, "helper");
expect(helpers).toHaveLength(1);
expect(helpers[0].source_location).toBe("L2");
});

it("only the first definition after a prototype replaces it", () => {
const ex = extractFile([
"int pick(void);", // L1
"#ifdef FAST", // L2
"int pick(void) { return 1; }", // L3
"#else", // L4
"int pick(void) { return 2; }", // L5
"#endif", // L6
"",
].join("\n"));
const picks = fnNodes(ex, "pick");
expect(picks).toHaveLength(1);
expect(picks[0].source_location).toBe("L3");
});
});