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.
This commit is contained in:
Matteo Di Mattia
2026-06-02 15:55:17 +02:00
parent 62fcf493bd
commit d4bbb6ca1c
4 changed files with 139 additions and 2 deletions
+19
View File
@@ -16,6 +16,7 @@
"@ast-grep/lang-go": "^0.0.5",
"@ast-grep/lang-java": "^0.0.6",
"@ast-grep/lang-kotlin": "^0.0.6",
"@ast-grep/lang-lua": "^0.0.7",
"@ast-grep/lang-php": "^0.0.6",
"@ast-grep/lang-python": "^0.0.5",
"@ast-grep/lang-ruby": "^0.0.6",
@@ -179,6 +180,24 @@
}
}
},
"node_modules/@ast-grep/lang-lua": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@ast-grep/lang-lua/-/lang-lua-0.0.7.tgz",
"integrity": "sha512-xGJpC06HTGSb2KPD5YEuzdj7wo4ZRCkMPWWUCzXUqP8stkoJYp2SW6VExeInSIMLnGyEMQAGdjCWttgyRzTGOQ==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@ast-grep/setup-lang": "0.0.6"
},
"peerDependencies": {
"tree-sitter-cli": "0.25.8"
},
"peerDependenciesMeta": {
"tree-sitter-cli": {
"optional": true
}
}
},
"node_modules/@ast-grep/lang-php": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@ast-grep/lang-php/-/lang-php-0.0.6.tgz",
+1
View File
@@ -70,6 +70,7 @@
"@ast-grep/lang-go": "^0.0.5",
"@ast-grep/lang-java": "^0.0.6",
"@ast-grep/lang-kotlin": "^0.0.6",
"@ast-grep/lang-lua": "^0.0.7",
"@ast-grep/lang-php": "^0.0.6",
"@ast-grep/lang-python": "^0.0.5",
"@ast-grep/lang-ruby": "^0.0.6",
+2 -1
View File
@@ -492,6 +492,7 @@ export function ensureDynamicLanguages(): void {
["scala", "@ast-grep/lang-scala"],
["bash", "@ast-grep/lang-bash"],
["php", "@ast-grep/lang-php"],
["lua", "@ast-grep/lang-lua"],
];
for (const [name, pkg] of langPackages) {
@@ -598,7 +599,7 @@ async function getGraphableFiles(
const fullPath = path.join(dir, entry.name);
const relPath = toForwardSlash(path.relative(projectPath, fullPath));
if (shouldIgnore(ig, relPath)) continue;
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath)) continue;
if (entry.isDirectory()) {
await walk(fullPath);
+117 -1
View File
@@ -142,7 +142,10 @@ export function extractSymbolsAndCalls(
if (langKey === "bash") {
return extractFromBash(source, relativePath, language, moduleSymbol);
}
// Dart, Lua, Svelte, Vue and others fall through to the regex fallback.
if (langKey === "lua") {
return extractFromLua(source, relativePath, language, moduleSymbol);
}
// Dart, Svelte, Vue and others fall through to the regex fallback.
return extractFromRegex(source, relativePath, language, moduleSymbol);
} catch (err) {
if (!symbolExtractionWarned.has(langKey)) {
@@ -160,6 +163,119 @@ export function extractSymbolsAndCalls(
}
}
// ── Lua (namespace tables: function T.f(), local function f(), T.f = function()) ──
/**
* Lua has no node-kind-specific extractor upstream and previously fell through
* to the regex fallback, which records `Mod` for `function Mod.parse()`.
* This walks the ast-grep Lua tree so namespace-table style (`Table.method`,
* the common Lua module/OOP idiom) resolves to precise qualified symbols plus
* their call sites.
*/
function extractFromLua(
source: string,
file: string,
language: string,
moduleSym: SymbolNode,
): ExtractedSymbols {
const root = parse("lua", source).root();
const symbols: SymbolNode[] = [moduleSym];
const scopes: ScopeFrame[] = [];
const NAME = new Set(["dot_index_expression", "method_index_expression", "identifier"]);
const KW = new Set([
"if", "for", "while", "return", "function", "local", "then", "do", "end",
"and", "or", "not", "elseif", "else", "in", "repeat", "until", "nil", "true", "false",
]);
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
const kidsOf = (n: any): any[] => {
try {
return n.children();
} catch {
return [];
}
};
const shortName = (qn: string): string => {
const parts = qn.split(/[.:]/);
return parts[parts.length - 1];
};
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
const addSym = (nameNode: any, rangeNode: any): void => {
const qn = nameNode.text().replace(/\s+/g, "");
if (!/^[A-Za-z_][\w]*([.:][A-Za-z_][\w]*)*$/.test(qn)) return;
const range = rangeNode.range();
const startLine = range.start.line + 1;
const endLine = range.end.line + 1;
const sym: SymbolNode = {
id: makeId(file, qn, startLine),
name: shortName(qn),
qualifiedName: qn,
kind: /[.:]/.test(qn) ? "method" : "function",
file,
line: startLine,
endLine,
language,
};
symbols.push(sym);
scopes.push({ name: qn, startLine, endLine, symbolId: sym.id });
};
// `function T.f()`, `function T:m()`, `function f()`, `local function f()` —
// the name is the DIRECT child before `parameters`, not a body expression.
for (const fn of safeFindAll(root, "function_declaration")) {
const kids = kidsOf(fn);
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
const pIdx = kids.findIndex((c: any) => c.kind() === "parameters");
const limit = pIdx < 0 ? kids.length : pIdx;
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
let nameNode: any = null;
for (let i = 0; i < limit; i++) {
if (NAME.has(kids[i].kind())) {
nameNode = kids[i];
break;
}
}
if (nameNode) addSym(nameNode, fn);
}
// `T.f = function() … end` / `local f = function() … end` — the RHS must be
// DIRECTLY a function_definition (don't match nested anonymous functions).
for (const assign of safeFindAll(root, "assignment_statement")) {
const kids = kidsOf(assign);
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
const rhs = kids.find((c: any) => c.kind() === "expression_list");
if (!rhs) continue;
const rhs0 = kidsOf(rhs)[0];
if (!rhs0 || rhs0.kind() !== "function_definition") continue;
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
const vl = kids.find((c: any) => c.kind() === "variable_list");
const nameNode = vl ? kidsOf(vl)[0] : null;
if (nameNode && NAME.has(nameNode.kind())) addSym(nameNode, assign);
}
// Calls — attribute each to its enclosing function scope (or <module>).
const rawCalls: ExtractedSymbols["rawCalls"] = [];
for (const call of safeFindAll(root, "function_call")) {
const fnExpr = kidsOf(call)[0];
if (!fnExpr) continue;
const ids = safeFindAll(fnExpr, "identifier");
const callee =
ids.length > 0
? ids[ids.length - 1].text()
: fnExpr.kind() === "identifier"
? fnExpr.text()
: null;
if (!callee || KW.has(callee)) continue;
const line = call.range().start.line + 1;
rawCalls.push({
callerId: findCallerId(scopes, line, moduleSym.id),
calleeName: callee,
callSite: { file, line },
});
}
return { symbols, rawCalls };
}
// ── JS / TS / TSX ────────────────────────────────────────────────────────
function extractFromTsLike(