feat: add Lua and Luau language support (#273)

Adds Lua (.lua) and Luau (.luau) extraction — functions, methods with receivers, type aliases (Luau), require imports (incl. Roblox instance-path), and call edges. Vendors the ABI-15 Lua and ABI-14 Luau tree-sitter grammars. Addresses #232.
This commit is contained in:
Colby Mchenry
2026-05-21 09:28:00 -05:00
committed by GitHub
parent 2fc0df7108
commit 4329a52bec
17 changed files with 969 additions and 3 deletions
+12 -2
View File
@@ -35,6 +35,8 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
dart: 'tree-sitter-dart.wasm',
pascal: 'tree-sitter-pascal.wasm',
scala: 'tree-sitter-scala.wasm',
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
};
/**
@@ -78,6 +80,8 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.fmx': 'pascal',
'.scala': 'scala',
'.sc': 'scala',
'.lua': 'lua',
'.luau': 'luau',
};
/**
@@ -125,8 +129,12 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
for (const lang of toLoad) {
const wasmFile = WASM_GRAMMAR_FILES[lang];
try {
// Pascal and Scala ship their own WASMs (not in tree-sitter-wasms)
const wasmPath = (lang === 'pascal' || lang === 'scala')
// Some grammars ship their own WASMs (not in tree-sitter-wasms, or the
// tree-sitter-wasms build is too old). Lua: tree-sitter-wasms ships an
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
@@ -291,6 +299,8 @@ export function getLanguageDisplayName(language: Language): string {
liquid: 'Liquid',
pascal: 'Pascal / Delphi',
scala: 'Scala',
lua: 'Lua',
luau: 'Luau',
unknown: 'Unknown',
};
return names[language] || language;
+4
View File
@@ -23,6 +23,8 @@ import { kotlinExtractor } from './kotlin';
import { dartExtractor } from './dart';
import { pascalExtractor } from './pascal';
import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -43,4 +45,6 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
dart: dartExtractor,
pascal: pascalExtractor,
scala: scalaExtractor,
lua: luaExtractor,
luau: luauExtractor,
};
+152
View File
@@ -0,0 +1,152 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
// Node names follow the vendored ABI-15 grammar (@tree-sitter-grammars/
// tree-sitter-lua), NOT the older tree-sitter-wasms build — see grammars.ts.
/** First descendant of a given type (breadth-first), or null. */
function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null {
const queue: SyntaxNode[] = [...node.namedChildren];
while (queue.length) {
const n = queue.shift()!;
if (n.type === type) return n;
queue.push(...n.namedChildren);
}
return null;
}
/**
* If `callNode` is a `require(...)` call, return the module name; otherwise null.
* Lua/Luau have no import statement — modules are loaded by calling the global
* `require`. Handles both:
* - string requires: `require("net.http")` / `require "net.http"` → "net.http"
* - Roblox/Luau path requires: `require(script.Parent.Signal)` → "Signal"
* (the dominant idiom in Roblox code, where the argument is an instance path
* rather than a string — use the trailing field as the module name).
*/
function requireModule(callNode: SyntaxNode, source: string): string | null {
// function_call > name: <callee>, arguments: arguments
const name = getChildByField(callNode, 'name');
// A dotted/colon callee (e.g. `socket.connect`) is dot/method_index_expression,
// never a bare `require`.
if (!name || name.type !== 'identifier') return null;
if (getNodeText(name, source) !== 'require') return null;
const args = getChildByField(callNode, 'arguments');
if (!args) return null;
// String require — `string > content: string_content` gives the bare name.
const content = findDescendant(args, 'string_content');
if (content) return getNodeText(content, source).trim() || null;
const str = findDescendant(args, 'string');
if (str) {
const mod = getNodeText(str, source)
.trim()
.replace(/^\[\[/, '')
.replace(/\]\]$/, '')
.replace(/^["']/, '')
.replace(/["']$/, '');
if (mod) return mod;
}
// Roblox/Luau instance-path require: `require(script.Parent.Signal)` → "Signal".
const idx = findDescendant(args, 'dot_index_expression') ?? findDescendant(args, 'method_index_expression');
if (idx) {
const field = getChildByField(idx, 'field') ?? getChildByField(idx, 'method');
if (field) return getNodeText(field, source).trim() || null;
}
return null;
}
export const luaExtractor: LanguageExtractor = {
// function_declaration covers global (`function f`), table (`function t.f`),
// method (`function t:m`), and local (`local function f`) forms — the form is
// distinguished by the `name:` child (identifier / dot_index_expression /
// method_index_expression) and a `local` token, not by separate node types.
// Anonymous `function() ... end` (function_definition) has no name and is
// captured via its enclosing variable instead.
functionTypes: ['function_declaration'],
classTypes: [], // Lua has no classes/structs/interfaces/enums — tables are used for everything
methodTypes: [],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: [], // `require` is a function_call — handled in visitNode below
callTypes: ['function_call'],
variableTypes: ['variable_declaration'], // see the `lua` branch in extractVariable
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
return params ? getNodeText(params, source) : undefined;
},
// `function t.f()` / `function t:m()` are methods on table `t`: return the
// table as the receiver so they extract as methods with a `t::f` qualified
// name. Plain `function f()` / `local function f()` have no receiver and stay
// functions. (For `a.b.c`, the receiver is the nested `a.b`.)
getReceiverType: (node, source) => {
const name = getChildByField(node, 'name');
if (name && (name.type === 'dot_index_expression' || name.type === 'method_index_expression')) {
const table = getChildByField(name, 'table');
if (table) return getNodeText(table, source);
}
return undefined;
},
// Emit import nodes for `require(...)`. The local-declaration form is handled
// explicitly because the variable branch skips the initializer subtree; bare
// and global `require` calls are caught when the walker reaches the
// function_call node.
visitNode: (node, ctx) => {
const source = ctx.source;
const emit = (callNode: SyntaxNode): void => {
const mod = requireModule(callNode, source);
if (!mod) return;
const imp = ctx.createNode('import', mod, callNode, {
signature: getNodeText(callNode, source).trim().slice(0, 100),
});
if (imp && ctx.nodeStack.length > 0) {
const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
if (parentId) {
ctx.addUnresolvedReference({
fromNodeId: parentId,
referenceName: mod,
referenceKind: 'imports',
line: callNode.startPosition.row + 1,
column: callNode.startPosition.column,
});
}
}
};
// Bare / global `require("x")` — claim it so it isn't double-counted as a call.
if (node.type === 'function_call') {
if (requireModule(node, source)) {
emit(node);
return true;
}
return false;
}
// `local x = require("x")` — variable_declaration wraps an assignment_statement
// whose initializer subtree the variable branch will skip, so dig it out here.
if (node.type === 'variable_declaration') {
const assign = node.namedChildren.find((c) => c.type === 'assignment_statement');
const exprList = assign?.namedChildren.find((c) => c.type === 'expression_list');
if (exprList) {
for (const val of exprList.namedChildren) {
if (val.type === 'function_call') emit(val);
}
}
return false;
}
return false;
},
};
+36
View File
@@ -0,0 +1,36 @@
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
import { luaExtractor } from './lua';
// Luau (https://luau.org) is a gradually-typed superset of Lua. The
// tree-sitter-luau grammar reuses the same node names as the vendored Lua
// grammar (function_declaration, variable_declaration, function_call,
// dot/method_index_expression, …), so the Luau extractor extends the Lua one
// and adds the type-system pieces Luau introduces:
// - `type X = ...` / `export type X = ...` → type_definition (type_alias)
// - typed parameters and return types → richer signatures
//
// require detection, receiver-splitting (t.f / t:m → methods), and local
// variable extraction are inherited unchanged from luaExtractor. The shared
// `extractVariable` core branch is gated on `lua` || `luau`.
export const luauExtractor: LanguageExtractor = {
...luaExtractor,
// `type X = ...` and `export type X = ...`
typeAliasTypes: ['type_definition'],
// Only Luau `export type` is exported; the keyword leads the node.
isExported: (node, source) => source.slice(node.startIndex, node.startIndex + 7) === 'export ',
// Params + Luau return type (the named child after `parameters`, before the body).
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
if (!params) return undefined;
let sig = getNodeText(params, source);
const kids = node.namedChildren;
const idx = kids.findIndex((c) => c.startIndex === params.startIndex);
const ret = idx >= 0 ? kids[idx + 1] : null;
if (ret && ret.type !== 'block') sig += `: ${getNodeText(ret, source)}`;
return sig;
},
};
+28
View File
@@ -50,6 +50,17 @@ function extractName(node: SyntaxNode, source: string, extractor: LanguageExtrac
const innerName = getChildByField(resolved, 'declarator') || resolved.namedChild(0);
return innerName ? getNodeText(innerName, source) : getNodeText(resolved, source);
}
// Lua: `function t.f()` / `function t:m()` — the name node is a dot/method
// index expression; the simple name is the trailing field/method (the table
// receiver is captured separately via getReceiverType).
if (resolved.type === 'dot_index_expression') {
const field = getChildByField(resolved, 'field');
if (field) return getNodeText(field, source);
}
if (resolved.type === 'method_index_expression') {
const method = getChildByField(resolved, 'method');
if (method) return getNodeText(method, source);
}
return getNodeText(resolved, source);
}
@@ -1111,6 +1122,23 @@ export class TreeSitterExtractor {
}
}
}
} else if (this.language === 'lua' || this.language === 'luau') {
// Lua/Luau: variable_declaration → assignment_statement → variable_list
// (name: identifier...) = expression_list. `local x, y = 1, 2`
// declares multiple names; only plain identifiers are locals.
const assign = node.namedChildren.find((c) => c.type === 'assignment_statement') ?? node;
const varList = assign.namedChildren.find((c) => c.type === 'variable_list');
const exprList = assign.namedChildren.find((c) => c.type === 'expression_list');
const values = exprList ? exprList.namedChildren : [];
const names = varList ? varList.namedChildren.filter((c) => c.type === 'identifier') : [];
names.forEach((nameNode, i) => {
const name = getNodeText(nameNode, this.source);
if (!name) return;
const valueNode = values[i];
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
this.createNode(kind, name, nameNode, { docstring, signature: initSignature, isExported });
});
} else {
// Generic fallback for other languages
// Try to find identifier children
Binary file not shown.
Binary file not shown.
+6
View File
@@ -85,6 +85,8 @@ export const LANGUAGES = [
'liquid',
'pascal',
'scala',
'lua',
'luau',
'unknown',
] as const;
@@ -545,6 +547,10 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
// Scala
'**/*.scala',
'**/*.sc',
// Lua
'**/*.lua',
// Luau
'**/*.luau',
],
exclude: [
// Version control