Skip to content

Commit f3d741d

Browse files
Merge pull request giancarloerra#67 from SIRTHEO/feat/lua-symbol-graph
feat(graph): Lua symbol/call extraction + fix file discovery for whitelist .gitignore
2 parents 3774855 + 8d23951 commit f3d741d

6 files changed

Lines changed: 287 additions & 17 deletions

File tree

package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"@ast-grep/lang-go": "^0.0.5",
7171
"@ast-grep/lang-java": "^0.0.6",
7272
"@ast-grep/lang-kotlin": "^0.0.6",
73+
"@ast-grep/lang-lua": "^0.0.7",
7374
"@ast-grep/lang-php": "^0.0.6",
7475
"@ast-grep/lang-python": "^0.0.5",
7576
"@ast-grep/lang-ruby": "^0.0.6",

src/services/code-graph.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ export function ensureDynamicLanguages(): void {
492492
["scala", "@ast-grep/lang-scala"],
493493
["bash", "@ast-grep/lang-bash"],
494494
["php", "@ast-grep/lang-php"],
495+
["lua", "@ast-grep/lang-lua"],
495496
];
496497

497498
for (const [name, pkg] of langPackages) {
@@ -578,7 +579,7 @@ export function getAstGrepLang(ext: string): Lang | string | null {
578579
* Get all source files in a project for graph analysis.
579580
* Includes files with known AST grammars and any extra extensions.
580581
*/
581-
async function getGraphableFiles(
582+
export async function getGraphableFiles(
582583
projectPath: string,
583584
extraExts?: Set<string>,
584585
): Promise<string[]> {
@@ -598,7 +599,7 @@ async function getGraphableFiles(
598599
const fullPath = path.join(dir, entry.name);
599600
const relPath = toForwardSlash(path.relative(projectPath, fullPath));
600601

601-
if (shouldIgnore(ig, relPath)) continue;
602+
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath)) continue;
602603

603604
if (entry.isDirectory()) {
604605
await walk(fullPath);

src/services/graph-symbols.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ export function extractSymbolsAndCalls(
142142
if (langKey === "bash") {
143143
return extractFromBash(source, relativePath, language, moduleSymbol);
144144
}
145-
// Dart, Lua, Svelte, Vue and others fall through to the regex fallback.
145+
if (langKey === "lua") {
146+
return extractFromLua(source, relativePath, language, moduleSymbol);
147+
}
148+
// Dart, Svelte, Vue and others fall through to the regex fallback.
146149
return extractFromRegex(source, relativePath, language, moduleSymbol);
147150
} catch (err) {
148151
if (!symbolExtractionWarned.has(langKey)) {
@@ -160,6 +163,119 @@ export function extractSymbolsAndCalls(
160163
}
161164
}
162165

166+
// ── Lua (namespace tables: function T.f(), local function f(), T.f = function()) ──
167+
168+
/**
169+
* Lua has no node-kind-specific extractor upstream and previously fell through
170+
* to the regex fallback, which records `Mod` for `function Mod.parse()`.
171+
* This walks the ast-grep Lua tree so namespace-table style (`Table.method`,
172+
* the common Lua module/OOP idiom) resolves to precise qualified symbols plus
173+
* their call sites.
174+
*/
175+
function extractFromLua(
176+
source: string,
177+
file: string,
178+
language: string,
179+
moduleSym: SymbolNode,
180+
): ExtractedSymbols {
181+
const root = parse("lua" as unknown as Lang, source).root();
182+
const symbols: SymbolNode[] = [moduleSym];
183+
const scopes: ScopeFrame[] = [];
184+
const NAME = new Set(["dot_index_expression", "method_index_expression", "identifier"]);
185+
const KW = new Set([
186+
"if", "for", "while", "return", "function", "local", "then", "do", "end",
187+
"and", "or", "not", "elseif", "else", "in", "repeat", "until", "nil", "true", "false",
188+
]);
189+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
190+
const kidsOf = (n: any): any[] => {
191+
try {
192+
return n.children();
193+
} catch {
194+
return [];
195+
}
196+
};
197+
const shortName = (qn: string): string => {
198+
const parts = qn.split(/[.:]/);
199+
return parts[parts.length - 1];
200+
};
201+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
202+
const addSym = (nameNode: any, rangeNode: any): void => {
203+
const qn = nameNode.text().replace(/\s+/g, "");
204+
if (!/^[A-Za-z_][\w]*([.:][A-Za-z_][\w]*)*$/.test(qn)) return;
205+
const range = rangeNode.range();
206+
const startLine = range.start.line + 1;
207+
const endLine = range.end.line + 1;
208+
const sym: SymbolNode = {
209+
id: makeId(file, qn, startLine),
210+
name: shortName(qn),
211+
qualifiedName: qn,
212+
kind: /[.:]/.test(qn) ? "method" : "function",
213+
file,
214+
line: startLine,
215+
endLine,
216+
language,
217+
};
218+
symbols.push(sym);
219+
scopes.push({ name: qn, startLine, endLine, symbolId: sym.id });
220+
};
221+
222+
// `function T.f()`, `function T:m()`, `function f()`, `local function f()` —
223+
// the name is the DIRECT child before `parameters`, not a body expression.
224+
for (const fn of safeFindAll(root, "function_declaration")) {
225+
const kids = kidsOf(fn);
226+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
227+
const pIdx = kids.findIndex((c: any) => c.kind() === "parameters");
228+
const limit = pIdx < 0 ? kids.length : pIdx;
229+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
230+
let nameNode: any = null;
231+
for (let i = 0; i < limit; i++) {
232+
if (NAME.has(kids[i].kind())) {
233+
nameNode = kids[i];
234+
break;
235+
}
236+
}
237+
if (nameNode) addSym(nameNode, fn);
238+
}
239+
240+
// `T.f = function() … end` / `local f = function() … end` — the RHS must be
241+
// DIRECTLY a function_definition (don't match nested anonymous functions).
242+
for (const assign of safeFindAll(root, "assignment_statement")) {
243+
const kids = kidsOf(assign);
244+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
245+
const rhs = kids.find((c: any) => c.kind() === "expression_list");
246+
if (!rhs) continue;
247+
const rhs0 = kidsOf(rhs)[0];
248+
if (!rhs0 || rhs0.kind() !== "function_definition") continue;
249+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
250+
const vl = kids.find((c: any) => c.kind() === "variable_list");
251+
const nameNode = vl ? kidsOf(vl)[0] : null;
252+
if (nameNode && NAME.has(nameNode.kind())) addSym(nameNode, assign);
253+
}
254+
255+
// Calls — attribute each to its enclosing function scope (or <module>).
256+
const rawCalls: ExtractedSymbols["rawCalls"] = [];
257+
for (const call of safeFindAll(root, "function_call")) {
258+
const fnExpr = kidsOf(call)[0];
259+
if (!fnExpr) continue;
260+
const ids = safeFindAll(fnExpr, "identifier");
261+
const callee =
262+
ids.length > 0
263+
? ids[ids.length - 1].text()
264+
: fnExpr.kind() === "identifier"
265+
? fnExpr.text()
266+
: null;
267+
if (!callee || KW.has(callee)) continue;
268+
const line = call.range().start.line + 1;
269+
rawCalls.push({
270+
callerId: findCallerId(scopes, line, moduleSym.id),
271+
calleeName: callee,
272+
callSite: { file, line },
273+
});
274+
}
275+
276+
return { symbols, rawCalls };
277+
}
278+
163279
// ── JS / TS / TSX ────────────────────────────────────────────────────────
164280

165281
function extractFromTsLike(

tests/unit/graph-discovery.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
3+
4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
8+
import { ensureDynamicLanguages, getGraphableFiles } from "../../src/services/code-graph.js";
9+
10+
// Regression for the whitelist .gitignore discovery fix: a `/*` then `!/src/`
11+
// pattern ignores everything at the root but re-includes `src/`. The old walk
12+
// passed `src` (no trailing slash) to shouldIgnore, which `/*` matched, so the
13+
// walk bailed and produced an empty graph. Passing `src/` lets it descend and
14+
// the files under the re-included directory are actually picked up.
15+
describe("getGraphableFiles — whitelist .gitignore", () => {
16+
let root: string;
17+
18+
beforeAll(() => {
19+
ensureDynamicLanguages();
20+
root = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-discovery-"));
21+
fs.mkdirSync(path.join(root, "src"), { recursive: true });
22+
fs.writeFileSync(path.join(root, ".gitignore"), "/*\n!/src/\n");
23+
fs.writeFileSync(
24+
path.join(root, "src", "mod.lua"),
25+
"local function f()\n return 1\nend\nreturn f\n",
26+
);
27+
// A root-level file the `/*` pattern should keep ignored.
28+
fs.writeFileSync(path.join(root, "ignored.lua"), "return 1\n");
29+
});
30+
31+
afterAll(() => {
32+
try {
33+
fs.rmSync(root, { recursive: true, force: true });
34+
} catch {
35+
// ignore cleanup errors
36+
}
37+
});
38+
39+
it("descends into re-included src/ and discovers its files", async () => {
40+
const files = await getGraphableFiles(root);
41+
expect(files).toContain("src/mod.lua");
42+
// The `/*` pattern still ignores top-level entries that are not re-included.
43+
expect(files).not.toContain("ignored.lua");
44+
});
45+
});

tests/unit/graph-symbols.test.ts

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -370,26 +370,48 @@ main() {
370370
});
371371
});
372372

373-
describe("Regex fallback (Dart, Lua, Svelte, Vue, unknown)", () => {
374-
it("handles Dart via regex fallback", () => {
373+
describe("Lua", () => {
374+
it("extracts namespace-table, method, local, and assignment function forms", () => {
375375
const src = `
376-
String greet(String name) {
377-
return 'Hi $name';
378-
}
376+
local T = {}
379377
380-
class Foo {
381-
int bar() => 1;
382-
}
378+
function T.method(a)
379+
return a
380+
end
381+
382+
function T:m()
383+
return self
384+
end
385+
386+
local function helper()
387+
return 1
388+
end
389+
390+
T.f = function()
391+
return 2
392+
end
393+
394+
return T
383395
`;
384-
const out = extractSymbolsAndCalls(src, "dart" as unknown as Lang, ".dart", "main.dart");
385-
// regex fallback should at least produce <module> and not throw
386-
expect(out.symbols.some((s) => s.name === "<module>")).toBe(true);
396+
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
387397
const names = out.symbols.map((s) => s.name);
388-
// best-effort detection: should find at least one named symbol
389-
expect(names.length).toBeGreaterThanOrEqual(1);
398+
const qnames = out.symbols.map((s) => s.qualifiedName);
399+
expect(names).toContain("<module>");
400+
// qualified Table.method / T:m() forms resolve to precise method symbols
401+
expect(qnames).toContain("T.method");
402+
expect(qnames).toContain("T:m");
403+
// `local function` keeps its bare name
404+
expect(names).toContain("helper");
405+
// `T.f = function() … end` assignment form
406+
expect(qnames).toContain("T.f");
407+
const kinds = out.symbols.filter((s) => s.qualifiedName === "T.method").map((s) => s.kind);
408+
expect(kinds).toContain("method");
409+
// colon-call form is also a method; the plain local function is not
410+
expect(out.symbols.find((s) => s.qualifiedName === "T:m")?.kind).toBe("method");
411+
expect(out.symbols.find((s) => s.qualifiedName === "helper")?.kind).toBe("function");
390412
});
391413

392-
it("handles Lua via regex fallback", () => {
414+
it("attributes calls to the enclosing function", () => {
393415
const src = `
394416
function greet(name)
395417
return "hi " .. name
@@ -401,6 +423,72 @@ end
401423
`;
402424
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
403425
expect(out.symbols.some((s) => s.name === "<module>")).toBe(true);
426+
const greetCall = out.rawCalls.find((c) => c.calleeName === "greet");
427+
expect(greetCall).toBeDefined();
428+
expect(greetCall?.callerId).toContain("::helper#");
429+
});
430+
431+
it("extracts the `local f = function() … end` assignment form", () => {
432+
const src = `
433+
local f = function()
434+
return 1
435+
end
436+
437+
local g = function()
438+
return f()
439+
end
440+
`;
441+
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
442+
const names = out.symbols.map((s) => s.name);
443+
expect(names).toContain("f");
444+
expect(names).toContain("g");
445+
expect(out.symbols.find((s) => s.qualifiedName === "f")?.kind).toBe("function");
446+
// call to `f` lives inside `g`, so it is attributed to `g`
447+
const fCall = out.rawCalls.find((c) => c.calleeName === "f");
448+
expect(fCall?.callerId).toContain("::g#");
449+
});
450+
451+
it("attributes top-level calls to <module> and resolves dotted callees", () => {
452+
const src = `
453+
local M = require("mod")
454+
455+
M.setup()
456+
457+
local function run()
458+
M.start()
459+
end
460+
`;
461+
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
462+
// calls outside any function fall back to the synthetic <module> scope
463+
const requireCall = out.rawCalls.find((c) => c.calleeName === "require");
464+
expect(requireCall?.callerId).toContain("::<module>#");
465+
// dotted callee `M.setup` resolves to the trailing identifier
466+
const setupCall = out.rawCalls.find((c) => c.calleeName === "setup");
467+
expect(setupCall).toBeDefined();
468+
expect(setupCall?.callerId).toContain("::<module>#");
469+
// `M.start()` lives inside `run`, so it is attributed there
470+
const startCall = out.rawCalls.find((c) => c.calleeName === "start");
471+
expect(startCall?.callerId).toContain("::run#");
472+
});
473+
});
474+
475+
describe("Regex fallback (Dart, Svelte, Vue, unknown)", () => {
476+
it("handles Dart via regex fallback", () => {
477+
const src = `
478+
String greet(String name) {
479+
return 'Hi $name';
480+
}
481+
482+
class Foo {
483+
int bar() => 1;
484+
}
485+
`;
486+
const out = extractSymbolsAndCalls(src, "dart" as unknown as Lang, ".dart", "main.dart");
487+
// regex fallback should at least produce <module> and not throw
488+
expect(out.symbols.some((s) => s.name === "<module>")).toBe(true);
489+
const names = out.symbols.map((s) => s.name);
490+
// best-effort detection: should find at least one named symbol
491+
expect(names.length).toBeGreaterThanOrEqual(1);
404492
});
405493

406494
it("handles unknown language without throwing", () => {

0 commit comments

Comments
 (0)