Skip to content

Commit 5b044ff

Browse files
committed
feat(graph): Lua symbol/call extraction + fix discovery for whitelist .gitignore
Two related graph-engine fixes that together make the code graph work on Lua projects (and unblock any repo using a whitelist-style .gitignore): - getGraphableFiles skipped every directory whose bare name matched .gitignore but was re-included only in trailing-slash form (e.g. `/*` then `!/src/`). The walk never descended, producing an empty graph for ALL languages on such repos. Check the directory form (trailing slash), per gitignore dir semantics. - Lua had no dedicated ast-grep extractor and fell through to the regex fallback, which records `Mod` for `function Mod.parse()`. Add extractFromLua (function_declaration dotted/method/local names, `T.f = function()` assigns, and call sites) and register the @ast-grep/lang-lua grammar, so namespace-table style resolves to precise qualified symbols.
1 parent 3774855 commit 5b044ff

4 files changed

Lines changed: 139 additions & 2 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: 2 additions & 1 deletion
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) {
@@ -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", 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(

0 commit comments

Comments
 (0)