diff --git a/crates/codegraph-core/src/extractors/lua.rs b/crates/codegraph-core/src/extractors/lua.rs index 24b5812d..54f46c39 100644 --- a/crates/codegraph-core/src/extractors/lua.rs +++ b/crates/codegraph-core/src/extractors/lua.rs @@ -359,4 +359,66 @@ mod tests { .iter() .any(|c| c.name == "fakeOs" && c.dynamic_kind.as_deref() == Some("value-ref"))); } + + // ── #1909: eval/computed-key dynamic-call detection ────────────────────── + + #[test] + fn classifies_load_call_as_dynamic_eval() { + let s = parse_lua("load(chunk)()"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("eval"))); + } + + #[test] + fn classifies_loadstring_call_as_dynamic_eval() { + let s = parse_lua("loadstring(code)()"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("eval"))); + } + + #[test] + fn classifies_dofile_call_as_dynamic_eval() { + let s = parse_lua("dofile(\"script.lua\")"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("eval"))); + } + + #[test] + fn resolves_bracket_index_call_with_string_literal_key_directly() { + let s = parse_lua("t[\"handler\"]()"); + assert!(s + .calls + .iter() + .any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t") && c.dynamic.is_none())); + } + + #[test] + fn resolves_bracket_index_call_with_single_quoted_string_literal_key_directly() { + let s = parse_lua("t['handler']()"); + assert!(s + .calls + .iter() + .any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t"))); + } + + #[test] + fn classifies_bracket_index_call_with_variable_key_as_computed_key() { + let s = parse_lua("t[k]()"); + assert!(s.calls.iter().any(|c| c.name == "" + && c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("computed-key") + && c.key_expr.as_deref() == Some("k") + && c.receiver.as_deref() == Some("t"))); + } + + #[test] + fn classifies_bracket_index_call_with_expression_key_as_computed_key() { + let s = parse_lua("handlers[eventName .. \"Handler\"]()"); + assert!(s.calls.iter().any(|c| c.dynamic == Some(true) + && c.dynamic_kind.as_deref() == Some("computed-key") + && c.receiver.as_deref() == Some("handlers"))); + } } diff --git a/src/extractors/lua.ts b/src/extractors/lua.ts index 468c3cfc..47687fef 100644 --- a/src/extractors/lua.ts +++ b/src/extractors/lua.ts @@ -246,10 +246,34 @@ function handleLuaAssignmentStatement(node: TreeSitterNode, ctx: ExtractorOutput } } +/** + * Lua string node types across grammar variants — `string` is the only kind + * the current `@tree-sitter-grammars/tree-sitter-lua` grammar (npm, used by + * this WASM extractor) produces; `string_literal` is included so this stays + * in lockstep with the native Rust extractor's own (equally defensive) check. + */ +const LUA_STRING_NODE_TYPES = new Set(['string', 'string_literal']); + function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; + // load(chunk) / loadstring(chunk) / dofile(...) — dynamic code execution; + // undecidable statically. Mirrors handle_lua_function_call's `load` arm in + // crates/codegraph-core/src/extractors/lua.rs. + if (nameNode.type === 'identifier') { + const ident = nameNode.text; + if (ident === 'load' || ident === 'loadstring' || ident === 'dofile') { + ctx.calls.push({ + name: '', + line: node.startPosition.row + 1, + dynamic: true, + dynamicKind: 'eval', + }); + return; + } + } + // Check for require() as import if (nameNode.type === 'identifier' && nameNode.text === 'require') { const args = node.childForFieldName('arguments'); @@ -279,6 +303,31 @@ function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void const field = nameNode.childForFieldName('field'); if (field) call.name = field.text; if (table) call.receiver = table.text; + } else if (nameNode.type === 'bracket_index_expression') { + // t[k]() — bracket-index call; key may be a string literal (resolvable + // directly, same as a `.field`/`:method` call) or a variable/expression + // (undecidable statically — flagged `computed-key`). Mirrors + // handle_lua_function_call's `bracket_index_expression` arm in + // crates/codegraph-core/src/extractors/lua.rs. + const table = nameNode.childForFieldName('table'); + const key = nameNode.childForFieldName('field'); + if (key) { + if (LUA_STRING_NODE_TYPES.has(key.type)) { + call.name = key.text.replace(/^['"]|['"]$/g, ''); + if (table) call.receiver = table.text; + } else { + const dynamicCall: Call = { + name: '', + line: node.startPosition.row + 1, + dynamic: true, + dynamicKind: 'computed-key', + keyExpr: key.text, + }; + if (table) dynamicCall.receiver = table.text; + ctx.calls.push(dynamicCall); + return; + } + } } else { call.name = nameNode.text; } diff --git a/tests/parsers/lua.test.ts b/tests/parsers/lua.test.ts index 1020a19a..0823cd65 100644 --- a/tests/parsers/lua.test.ts +++ b/tests/parsers/lua.test.ts @@ -144,4 +144,66 @@ string.format("%s", name)`); ); }); }); + + describe('eval/computed-key dynamic-call detection (#1909)', () => { + it('classifies load(...) as a dynamic eval call', () => { + const symbols = parseLua(`load(chunk)()`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'eval' }), + ); + }); + + it('classifies loadstring(...) as a dynamic eval call', () => { + const symbols = parseLua(`loadstring(code)()`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'eval' }), + ); + }); + + it('classifies dofile(...) as a dynamic eval call', () => { + const symbols = parseLua(`dofile("script.lua")`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'eval' }), + ); + }); + + it('resolves a bracket-index call with a string-literal key directly', () => { + const symbols = parseLua(`t["handler"]()`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'handler', receiver: 't' }), + ); + expect(symbols.calls.some((c) => c.dynamicKind === 'computed-key')).toBe(false); + }); + + it('resolves a bracket-index call with a single-quoted string-literal key directly', () => { + const symbols = parseLua(`t['handler']()`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'handler', receiver: 't' }), + ); + }); + + it('classifies a bracket-index call with a variable key as computed-key', () => { + const symbols = parseLua(`t[k]()`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: '', + dynamic: true, + dynamicKind: 'computed-key', + keyExpr: 'k', + receiver: 't', + }), + ); + }); + + it('classifies a bracket-index call with an expression key as computed-key', () => { + const symbols = parseLua(`handlers[eventName .. "Handler"]()`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + dynamic: true, + dynamicKind: 'computed-key', + receiver: 'handlers', + }), + ); + }); + }); });