Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,87 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
}
}

/// RES-2: Inline object-literal dispatch table — `({a:fnA,b:fnB})[key]()`.
///
/// Mirrors `extractSubscriptCallInfo` in `src/extractors/javascript.ts` (lines 3196–3233).
/// When the subscript object is an object literal (or parenthesized object literal) and
/// the index is an identifier, collect each value identifier as an array-elem binding
/// under a synthetic `<dt_line_col>` name, then return a `<dt_line_col>[*]` call so
/// the PTS solver can resolve the wildcard to each concrete target function.
///
/// Returns `None` if the pattern does not match (caller falls through to `extract_call_info`).
fn extract_dispatch_table_call(
fn_node: &Node,
call_node: &Node,
source: &[u8],
array_elem_bindings: &mut Vec<ArrayElemBinding>,
) -> Option<Call> {
let index = fn_node.child_by_field_name("index")?;
if index.kind() != "identifier" {
return None;
}
let obj = fn_node.child_by_field_name("object")?;
// Unwrap parenthesized_expression: ({a:fn})[key]()
let obj_node = if obj.kind() == "parenthesized_expression" {
// child(1) skips the opening paren; field "expression" is not always available
obj.child_by_field_name("expression")
.or_else(|| obj.child(1))
.unwrap_or(obj)
} else {
obj
};
if obj_node.kind() != "object" {
return None;
}
let line = start_line(call_node);
let col = call_node.start_position().column;
let table_name = format!("<dt_{}_{}>", line, col);
let mut idx: u32 = 0;
for i in 0..obj_node.child_count() {
let Some(child) = obj_node.child(i) else { continue };
match child.kind() {
"shorthand_property_identifier" => {
let text = node_text(&child, source);
if !JS_BUILTIN_GLOBALS.contains(&text) {
array_elem_bindings.push(ArrayElemBinding {
array_name: table_name.clone(),
index: idx,
elem_name: text.to_string(),
});
idx += 1;
}
}
"pair" => {
if let Some(val) = child.child_by_field_name("value") {
if val.kind() == "identifier" {
let text = node_text(&val, source);
if !JS_BUILTIN_GLOBALS.contains(&text) {
array_elem_bindings.push(ArrayElemBinding {
array_name: table_name.clone(),
index: idx,
elem_name: text.to_string(),
});
idx += 1;
}
}
}
}
Comment on lines +1511 to +1525

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 idx not incremented for non-identifier pair values

When a pair's value is a non-identifier expression (e.g., an arrow function or a literal like {a: () => {}, b: fnB}), idx is not incremented, so fnB gets index 0 instead of 1. The existing collect_array_elem_bindings (line ~3272) always increments idx for every non-punctuation element regardless of whether it contributes a binding, preserving positional accuracy. Here indices are consecutive only over identifier-valued entries.

This doesn't affect [*]-wildcard resolution (the PTS solver generates a constraint for every entry regardless of its index), but it does affect the spread_arg_bindings codepath in build_edges.rs (lines 251-278): that path iterates 0..=array_max_index to map positional spread parameters, and array_max_index is derived from the highest index stored in array_elem_bindings. If any future caller seeds a spread-arg binding against a dispatch-table name, the index gap would silently misalign parameter slots.

Fix in Claude Code

_ => {}
}
}
if idx == 0 {
return None;
}
Some(Call {
name: format!("{}[*]", table_name),
line,
dynamic: Some(true),
dynamic_kind: Some("dispatch-table".to_string()),
key_expr: Some(node_text(&index, source).to_string()),
..Default::default()
})
}

fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
let Some(fn_node) = node.child_by_field_name("function") else { return };
if fn_node.kind() == "import" {
Expand All @@ -1477,6 +1558,22 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
if fn_node.kind() == "this" || fn_node.kind() == "super" {
return;
}
// RES-2: {a:fnA,b:fnB}[k]() — inline object literal dispatch table.
// Mirrors extractSubscriptCallInfo in src/extractors/javascript.ts (lines 3196–3233).
// When the callee is a subscript_expression whose object is an object literal
// (possibly wrapped in parentheses) and whose index is an identifier, collect
// the values as array-elem bindings under a synthetic `<dt_line_col>` name and
// emit a `<dt_line_col>[*]` call so the PTS solver can resolve each target.
if fn_node.kind() == "subscript_expression" {
if let Some(call) = extract_dispatch_table_call(&fn_node, node, source, &mut symbols.array_elem_bindings) {
symbols.calls.push(call);
if let Some(cb_def) = extract_callback_definition(node, source) {
symbols.definitions.push(cb_def);
}
extract_callback_reference_calls(node, source, &mut symbols.calls);
return;
}
}
if let Some(call_info) = extract_call_info(&fn_node, node, source) {
symbols.calls.push(call_info);
}
Expand Down Expand Up @@ -4900,4 +4997,41 @@ mod tests {
assert!(call.is_some(), "bark() call missing; got: {:?}", s.calls);
assert_eq!(call.unwrap().receiver.as_deref(), Some("Dog"));
}

// RES-2: inline object-literal dispatch table — `({a:fnA,b:fnB})[key]()`
// Mirrors WASM extractSubscriptCallInfo dispatch-table branch (javascript.ts:3196–3233).
#[test]
fn dispatch_table_emits_dt_call_and_array_elem_bindings() {
let s = parse_js(
"function dtFn1() {}\n\
function dtFn2() {}\n\
function runDispatch(key) { ({ a: dtFn1, b: dtFn2 })[key](); }",
);
// The call name must be <dt_line_col>[*]
let dt_call = s.calls.iter().find(|c| c.name.starts_with("<dt_") && c.name.ends_with(">[*]"));
assert!(dt_call.is_some(), "dispatch-table call missing; got: {:?}", s.calls);
let dt_call = dt_call.unwrap();
assert_eq!(dt_call.dynamic, Some(true));
assert_eq!(dt_call.dynamic_kind.as_deref(), Some("dispatch-table"));

// The array_elem_bindings must contain dtFn1 and dtFn2 under the same table name
let table_name = dt_call.name.trim_end_matches("[*]");
let elem1 = s.array_elem_bindings.iter().find(|b| b.array_name == table_name && b.elem_name == "dtFn1");
let elem2 = s.array_elem_bindings.iter().find(|b| b.array_name == table_name && b.elem_name == "dtFn2");
assert!(elem1.is_some(), "dtFn1 array_elem_binding missing; got: {:?}", s.array_elem_bindings);
assert!(elem2.is_some(), "dtFn2 array_elem_binding missing; got: {:?}", s.array_elem_bindings);
assert_eq!(elem1.unwrap().index, 0);
assert_eq!(elem2.unwrap().index, 1);
}

#[test]
fn dispatch_table_parenthesized_object_also_works() {
let s = parse_js(
"function fnA() {}\n\
function fnB() {}\n\
function run(k) { ({a: fnA, b: fnB})[k](); }",
);
let dt_call = s.calls.iter().find(|c| c.name.starts_with("<dt_") && c.name.ends_with(">[*]"));
assert!(dt_call.is_some(), "dispatch-table call missing for parenthesized object; got: {:?}", s.calls);
}
}
Comment on lines +5030 to 5037

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Second test covers the same pattern as the first

Both dispatch_table_emits_dt_call_and_array_elem_bindings and dispatch_table_parenthesized_object_also_works use a parenthesized object literal ({...})[k](). The second test's name implies it specifically verifies the parenthesized path, but so does the first — there is no test for the unparenthesized form obj_literal[k]() where the subscript object is a bare object node (which can appear in non-statement expression contexts, e.g., as an argument). The non-parenthesized branch in extract_dispatch_table_call goes through else { obj } and is never exercised by the current test suite.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Loading