@@ -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 - Z a - z _ ] [ \w ] * ( [ . : ] [ A - Z a - 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
165281function extractFromTsLike (
0 commit comments