Skip to content

Commit 2810b4c

Browse files
committed
fix(lua): port eval/computed-key dynamic-call detection to WASM extractor
The native Rust Lua extractor already detected load()/loadstring()/dofile() as eval and t[k]() bracket-index calls as computed-key (or resolved directly for a string-literal key), but src/extractors/lua.ts never had the equivalent logic despite a prior commit claiming parity — so `--engine wasm` and `--engine native` diverged on any Lua file using these patterns. Ports handleLuaFunctionCall's eval/bracket-index handling from lua.rs into lua.ts, matching the Call shape (dynamic/dynamicKind/keyExpr/receiver) used by the other TS extractors. No changes to build-edges.ts/incremental.ts are needed since eval/computed-key are already generic FLAG_ONLY_DYNAMIC_KINDS. Also adds Rust unit test coverage for this logic, which had none. Impact: 1 functions changed, 2 affected
1 parent 771a64c commit 2810b4c

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

crates/codegraph-core/src/extractors/lua.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,66 @@ mod tests {
359359
.iter()
360360
.any(|c| c.name == "fakeOs" && c.dynamic_kind.as_deref() == Some("value-ref")));
361361
}
362+
363+
// ── #1909: eval/computed-key dynamic-call detection ──────────────────────
364+
365+
#[test]
366+
fn classifies_load_call_as_dynamic_eval() {
367+
let s = parse_lua("load(chunk)()");
368+
assert!(s.calls.iter().any(|c| c.name == "<dynamic:eval>"
369+
&& c.dynamic == Some(true)
370+
&& c.dynamic_kind.as_deref() == Some("eval")));
371+
}
372+
373+
#[test]
374+
fn classifies_loadstring_call_as_dynamic_eval() {
375+
let s = parse_lua("loadstring(code)()");
376+
assert!(s.calls.iter().any(|c| c.name == "<dynamic:eval>"
377+
&& c.dynamic == Some(true)
378+
&& c.dynamic_kind.as_deref() == Some("eval")));
379+
}
380+
381+
#[test]
382+
fn classifies_dofile_call_as_dynamic_eval() {
383+
let s = parse_lua("dofile(\"script.lua\")");
384+
assert!(s.calls.iter().any(|c| c.name == "<dynamic:eval>"
385+
&& c.dynamic == Some(true)
386+
&& c.dynamic_kind.as_deref() == Some("eval")));
387+
}
388+
389+
#[test]
390+
fn resolves_bracket_index_call_with_string_literal_key_directly() {
391+
let s = parse_lua("t[\"handler\"]()");
392+
assert!(s
393+
.calls
394+
.iter()
395+
.any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t") && c.dynamic.is_none()));
396+
}
397+
398+
#[test]
399+
fn resolves_bracket_index_call_with_single_quoted_string_literal_key_directly() {
400+
let s = parse_lua("t['handler']()");
401+
assert!(s
402+
.calls
403+
.iter()
404+
.any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t")));
405+
}
406+
407+
#[test]
408+
fn classifies_bracket_index_call_with_variable_key_as_computed_key() {
409+
let s = parse_lua("t[k]()");
410+
assert!(s.calls.iter().any(|c| c.name == "<dynamic:computed-key>"
411+
&& c.dynamic == Some(true)
412+
&& c.dynamic_kind.as_deref() == Some("computed-key")
413+
&& c.key_expr.as_deref() == Some("k")
414+
&& c.receiver.as_deref() == Some("t")));
415+
}
416+
417+
#[test]
418+
fn classifies_bracket_index_call_with_expression_key_as_computed_key() {
419+
let s = parse_lua("handlers[eventName .. \"Handler\"]()");
420+
assert!(s.calls.iter().any(|c| c.dynamic == Some(true)
421+
&& c.dynamic_kind.as_deref() == Some("computed-key")
422+
&& c.receiver.as_deref() == Some("handlers")));
423+
}
362424
}

src/extractors/lua.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,34 @@ function handleLuaAssignmentStatement(node: TreeSitterNode, ctx: ExtractorOutput
246246
}
247247
}
248248

249+
/**
250+
* Lua string node types across grammar variants — `string` is the only kind
251+
* the current `@tree-sitter-grammars/tree-sitter-lua` grammar (npm, used by
252+
* this WASM extractor) produces; `string_literal` is included so this stays
253+
* in lockstep with the native Rust extractor's own (equally defensive) check.
254+
*/
255+
const LUA_STRING_NODE_TYPES = new Set(['string', 'string_literal']);
256+
249257
function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void {
250258
const nameNode = node.childForFieldName('name');
251259
if (!nameNode) return;
252260

261+
// load(chunk) / loadstring(chunk) / dofile(...) — dynamic code execution;
262+
// undecidable statically. Mirrors handle_lua_function_call's `load` arm in
263+
// crates/codegraph-core/src/extractors/lua.rs.
264+
if (nameNode.type === 'identifier') {
265+
const ident = nameNode.text;
266+
if (ident === 'load' || ident === 'loadstring' || ident === 'dofile') {
267+
ctx.calls.push({
268+
name: '<dynamic:eval>',
269+
line: node.startPosition.row + 1,
270+
dynamic: true,
271+
dynamicKind: 'eval',
272+
});
273+
return;
274+
}
275+
}
276+
253277
// Check for require() as import
254278
if (nameNode.type === 'identifier' && nameNode.text === 'require') {
255279
const args = node.childForFieldName('arguments');
@@ -279,6 +303,31 @@ function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void
279303
const field = nameNode.childForFieldName('field');
280304
if (field) call.name = field.text;
281305
if (table) call.receiver = table.text;
306+
} else if (nameNode.type === 'bracket_index_expression') {
307+
// t[k]() — bracket-index call; key may be a string literal (resolvable
308+
// directly, same as a `.field`/`:method` call) or a variable/expression
309+
// (undecidable statically — flagged `computed-key`). Mirrors
310+
// handle_lua_function_call's `bracket_index_expression` arm in
311+
// crates/codegraph-core/src/extractors/lua.rs.
312+
const table = nameNode.childForFieldName('table');
313+
const key = nameNode.childForFieldName('field');
314+
if (key) {
315+
if (LUA_STRING_NODE_TYPES.has(key.type)) {
316+
call.name = key.text.replace(/^['"]|['"]$/g, '');
317+
if (table) call.receiver = table.text;
318+
} else {
319+
const dynamicCall: Call = {
320+
name: '<dynamic:computed-key>',
321+
line: node.startPosition.row + 1,
322+
dynamic: true,
323+
dynamicKind: 'computed-key',
324+
keyExpr: key.text,
325+
};
326+
if (table) dynamicCall.receiver = table.text;
327+
ctx.calls.push(dynamicCall);
328+
return;
329+
}
330+
}
282331
} else {
283332
call.name = nameNode.text;
284333
}

tests/parsers/lua.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,66 @@ string.format("%s", name)`);
144144
);
145145
});
146146
});
147+
148+
describe('eval/computed-key dynamic-call detection (#1909)', () => {
149+
it('classifies load(...) as a dynamic eval call', () => {
150+
const symbols = parseLua(`load(chunk)()`);
151+
expect(symbols.calls).toContainEqual(
152+
expect.objectContaining({ name: '<dynamic:eval>', dynamic: true, dynamicKind: 'eval' }),
153+
);
154+
});
155+
156+
it('classifies loadstring(...) as a dynamic eval call', () => {
157+
const symbols = parseLua(`loadstring(code)()`);
158+
expect(symbols.calls).toContainEqual(
159+
expect.objectContaining({ name: '<dynamic:eval>', dynamic: true, dynamicKind: 'eval' }),
160+
);
161+
});
162+
163+
it('classifies dofile(...) as a dynamic eval call', () => {
164+
const symbols = parseLua(`dofile("script.lua")`);
165+
expect(symbols.calls).toContainEqual(
166+
expect.objectContaining({ name: '<dynamic:eval>', dynamic: true, dynamicKind: 'eval' }),
167+
);
168+
});
169+
170+
it('resolves a bracket-index call with a string-literal key directly', () => {
171+
const symbols = parseLua(`t["handler"]()`);
172+
expect(symbols.calls).toContainEqual(
173+
expect.objectContaining({ name: 'handler', receiver: 't' }),
174+
);
175+
expect(symbols.calls.some((c) => c.dynamicKind === 'computed-key')).toBe(false);
176+
});
177+
178+
it('resolves a bracket-index call with a single-quoted string-literal key directly', () => {
179+
const symbols = parseLua(`t['handler']()`);
180+
expect(symbols.calls).toContainEqual(
181+
expect.objectContaining({ name: 'handler', receiver: 't' }),
182+
);
183+
});
184+
185+
it('classifies a bracket-index call with a variable key as computed-key', () => {
186+
const symbols = parseLua(`t[k]()`);
187+
expect(symbols.calls).toContainEqual(
188+
expect.objectContaining({
189+
name: '<dynamic:computed-key>',
190+
dynamic: true,
191+
dynamicKind: 'computed-key',
192+
keyExpr: 'k',
193+
receiver: 't',
194+
}),
195+
);
196+
});
197+
198+
it('classifies a bracket-index call with an expression key as computed-key', () => {
199+
const symbols = parseLua(`handlers[eventName .. "Handler"]()`);
200+
expect(symbols.calls).toContainEqual(
201+
expect.objectContaining({
202+
dynamic: true,
203+
dynamicKind: 'computed-key',
204+
receiver: 'handlers',
205+
}),
206+
);
207+
});
208+
});
147209
});

0 commit comments

Comments
 (0)