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
+177
View File
@@ -3722,3 +3722,180 @@ class Svc {
expect(decoratedNode?.name).toBe('method');
});
});
// =============================================================================
// Lua
// =============================================================================
describe('Lua Extraction', () => {
describe('Language detection', () => {
it('should detect Lua files', () => {
expect(detectLanguage('init.lua')).toBe('lua');
expect(detectLanguage('src/util.lua')).toBe('lua');
});
it('should report Lua as supported', () => {
expect(isLanguageSupported('lua')).toBe(true);
expect(getSupportedLanguages()).toContain('lua');
});
});
describe('Function extraction', () => {
it('should extract global and local functions', () => {
const code = `
function configure(opts) return opts end
local function helper(x) return x * 2 end
`;
const result = extractFromSource('init.lua', code);
const funcs = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(funcs).toContain('configure');
expect(funcs).toContain('helper');
const configure = result.nodes.find((n) => n.name === 'configure');
expect(configure?.language).toBe('lua');
expect(configure?.signature).toBe('(opts)');
});
it('should split table/method functions into a receiver and method name', () => {
const code = `
function M.connect(host, port) return host end
function M:send(data) return self end
`;
const result = extractFromSource('init.lua', code);
const methods = result.nodes.filter((n) => n.kind === 'method');
const connect = methods.find((m) => m.name === 'connect');
expect(connect?.qualifiedName).toBe('M::connect');
const send = methods.find((m) => m.name === 'send');
expect(send?.qualifiedName).toBe('M::send');
});
});
describe('Variable extraction', () => {
it('should extract local variable declarations', () => {
const code = `
local M = {}
local count = 0
`;
const result = extractFromSource('mod.lua', code);
const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
expect(vars).toContain('M');
expect(vars).toContain('count');
});
});
describe('Import extraction (require)', () => {
it('should extract require() in local declarations and bare calls', () => {
const code = `
local socket = require("socket")
local http = require "resty.http"
require("side.effect")
`;
const result = extractFromSource('net.lua', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('socket');
expect(imports).toContain('resty.http');
expect(imports).toContain('side.effect');
const ref = result.unresolvedReferences.find(
(r) => r.referenceKind === 'imports' && r.referenceName === 'socket'
);
expect(ref).toBeDefined();
});
// Regression: the tree-sitter-wasms Lua grammar (ABI 13) corrupts the shared
// WASM heap under web-tree-sitter 0.25, dropping nested calls/imports on every
// parse after the first. We vendor the ABI-15 grammar instead — this guards it
// by extracting several sources in sequence and asserting the LAST still works.
it('should keep extracting require across many sequential parses', () => {
let last;
for (let i = 0; i < 8; i++) {
last = extractFromSource(`f${i}.lua`, `local m = require("module.${i}")\nreturn m\n`);
}
const imports = last!.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('module.7');
});
});
describe('Call extraction', () => {
it('should record intra-file calls as resolvable references', () => {
const code = `
local function helper(x) return x end
local function run(y) return helper(y) end
`;
const result = extractFromSource('calls.lua', code);
const call = result.unresolvedReferences.find(
(r) => r.referenceKind === 'calls' && r.referenceName === 'helper'
);
expect(call).toBeDefined();
});
});
});
// =============================================================================
// Luau (typed superset of Lua — https://luau.org)
// =============================================================================
describe('Luau Extraction', () => {
describe('Language detection', () => {
it('should detect Luau files', () => {
expect(detectLanguage('init.luau')).toBe('luau');
expect(detectLanguage('src/Client.luau')).toBe('luau');
});
it('should report Luau as supported', () => {
expect(isLanguageSupported('luau')).toBe(true);
expect(getSupportedLanguages()).toContain('luau');
});
});
describe('Type aliases', () => {
it('should extract `type` and `export type` definitions', () => {
const code = `
export type Vector = { x: number, y: number }
type Handler = (msg: string) -> boolean
`;
const result = extractFromSource('types.luau', code);
const aliases = result.nodes.filter((n) => n.kind === 'type_alias');
const vector = aliases.find((a) => a.name === 'Vector');
expect(vector).toBeDefined();
expect(vector?.isExported).toBe(true);
const handler = aliases.find((a) => a.name === 'Handler');
expect(handler).toBeDefined();
expect(handler?.isExported).toBe(false);
});
});
describe('Typed functions and methods', () => {
it('should capture typed signatures and split methods by receiver', () => {
const code = `
function configure(opts: { debug: boolean }): boolean
return opts.debug
end
function Client:fetch(path: string): Response
return path
end
`;
const result = extractFromSource('client.luau', code);
const configure = result.nodes.find((n) => n.kind === 'function' && n.name === 'configure');
expect(configure?.language).toBe('luau');
expect(configure?.signature).toBe('(opts: { debug: boolean }): boolean');
const fetch = result.nodes.find((n) => n.kind === 'method' && n.name === 'fetch');
expect(fetch?.qualifiedName).toBe('Client::fetch');
});
});
describe('Imports and variables', () => {
it('should extract string and Roblox instance-path require imports', () => {
const code = `
local http = require("http")
local Signal = require(script.Parent.Signal)
local count = 0
`;
const result = extractFromSource('mod.luau', code);
const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
expect(imports).toContain('http'); // string require
expect(imports).toContain('Signal'); // Roblox instance-path require
const vars = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
expect(vars).toContain('count');
});
});
});