Răsfoiți Sursa

feat(luau): add Luau language support

Index Luau (.luau), Roblox's typed superset of Lua. The luau extractor extends the Lua one and adds: `type`/`export type` aliases, typed function signatures, generics, and Roblox instance-path `require(script.Parent.X)` imports — alongside inherited functions, receiver-split methods, local variables, and call edges. Vendor the upstream ABI-14 tree-sitter-luau grammar.

The Roblox-path require handling lives in the shared Lua require helper (harmless for plain Lua). Addresses the Luau request in #232.
Colby McHenry 1 lună în urmă
părinte
comite
096114a776

+ 5 - 0
.claude/skills/agent-eval/corpus.json

@@ -64,5 +64,10 @@
     { "name": "lualine.nvim", "repo": "https://github.com/nvim-lualine/lualine.nvim", "size": "Small", "files": "~120", "question": "How does lualine assemble and render its statusline sections and components?" },
     { "name": "telescope.nvim", "repo": "https://github.com/nvim-telescope/telescope.nvim", "size": "Medium", "files": "~80", "question": "How does Telescope wire a picker to its finder, sorter, and previewer?" },
     { "name": "kong", "repo": "https://github.com/Kong/kong", "size": "Large", "files": "~1330", "question": "How does Kong execute plugins across a request's lifecycle phases?" }
+  ],
+  "Luau": [
+    { "name": "Knit", "repo": "https://github.com/Sleitnick/Knit", "size": "Small", "files": "~10", "question": "How does Knit register services and expose them to clients?" },
+    { "name": "vide", "repo": "https://github.com/centau/vide", "size": "Small", "files": "~40", "question": "How does vide track reactive sources and re-run effects when state changes?" },
+    { "name": "Fusion", "repo": "https://github.com/dphfox/Fusion", "size": "Medium", "files": "~115", "question": "How does Fusion build and update its reactive UI graph from state objects?" }
   ]
 }

+ 5 - 0
CHANGELOG.md

@@ -15,6 +15,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   local variables, `require(...)` imports, and the call edges between them.
   Querying a Lua project (Neovim plugins, Kong, OpenResty, game code) now
   surfaces its modules, methods, and call graph.
+- **Luau** ([#232](https://github.com/colbymchenry/codegraph/issues/232)):
+  CodeGraph now indexes Luau (`.luau`), Roblox's typed superset of Lua —
+  everything Lua extracts, plus `type` / `export type` aliases, typed function
+  signatures, generics, and Roblox instance-path `require(script.Parent.X)`
+  imports.
 
 ## [0.8.0] - 2026-05-20
 

+ 2 - 1
README.md

@@ -107,7 +107,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
 | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
-| **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Svelte, Liquid, Pascal/Delphi |
+| **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 13 frameworks |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
 
@@ -448,6 +448,7 @@ The `.codegraph/config.json` file controls indexing:
 | Liquid | `.liquid` | Full support |
 | Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) |
 | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) |
+| Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
 
 ## Troubleshooting
 

+ 70 - 0
__tests__/extraction.test.ts

@@ -3829,3 +3829,73 @@ local function run(y) return helper(y) end
     });
   });
 });
+
+// =============================================================================
+// 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');
+    });
+  });
+});

+ 4 - 1
src/extraction/grammars.ts

@@ -36,6 +36,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
   pascal: 'tree-sitter-pascal.wasm',
   scala: 'tree-sitter-scala.wasm',
   lua: 'tree-sitter-lua.wasm',
+  luau: 'tree-sitter-luau.wasm',
 };
 
 /**
@@ -80,6 +81,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
   '.scala': 'scala',
   '.sc': 'scala',
   '.lua': 'lua',
+  '.luau': 'luau',
 };
 
 /**
@@ -132,7 +134,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
       // 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')
+      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);
@@ -298,6 +300,7 @@ export function getLanguageDisplayName(language: Language): string {
     pascal: 'Pascal / Delphi',
     scala: 'Scala',
     lua: 'Lua',
+    luau: 'Luau',
     unknown: 'Unknown',
   };
   return names[language] || language;

+ 2 - 0
src/extraction/languages/index.ts

@@ -24,6 +24,7 @@ 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,
@@ -45,4 +46,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   pascal: pascalExtractor,
   scala: scalaExtractor,
   lua: luaExtractor,
+  luau: luauExtractor,
 };

+ 26 - 13
src/extraction/languages/lua.ts

@@ -17,9 +17,13 @@ function findDescendant(node: SyntaxNode, type: string): SyntaxNode | null {
 }
 
 /**
- * If `callNode` is a `require("module")` / `require "module"` call, return the
- * bare module string; otherwise null. Lua has no import statement — modules are
- * loaded by calling the global `require`.
+ * 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
@@ -31,19 +35,28 @@ function requireModule(callNode: SyntaxNode, source: string): string | null {
 
   const args = getChildByField(callNode, 'arguments');
   if (!args) return null;
-  // `string > content: string_content` gives the bare module name (no quotes).
+
+  // String require — `string > content: string_content` gives the bare name.
   const content = findDescendant(args, 'string_content');
   if (content) return getNodeText(content, source).trim() || null;
-  // Fallback: a string node without a content child — strip delimiters.
   const str = findDescendant(args, 'string');
-  if (!str) return null;
-  const mod = getNodeText(str, source)
-    .trim()
-    .replace(/^\[\[/, '')
-    .replace(/\]\]$/, '')
-    .replace(/^["']/, '')
-    .replace(/["']$/, '');
-  return mod || null;
+  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 = {

+ 36 - 0
src/extraction/languages/luau.ts

@@ -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;
+  },
+};

+ 2 - 2
src/extraction/tree-sitter.ts

@@ -1122,8 +1122,8 @@ export class TreeSitterExtractor {
           }
         }
       }
-    } else if (this.language === 'lua') {
-      // Lua: variable_declaration → assignment_statement → variable_list
+    } 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;

BIN
src/extraction/wasm/tree-sitter-luau.wasm


+ 3 - 0
src/types.ts

@@ -86,6 +86,7 @@ export const LANGUAGES = [
   'pascal',
   'scala',
   'lua',
+  'luau',
   'unknown',
 ] as const;
 
@@ -548,6 +549,8 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
     '**/*.sc',
     // Lua
     '**/*.lua',
+    // Luau
+    '**/*.luau',
   ],
   exclude: [
     // Version control