Skip to content

Commit 2ac24ef

Browse files
feat: extract symbols from Commander/Express/Event callback patterns
Anonymous callbacks in framework patterns (.action(), .get(), .on()) were invisible to the graph, making files like cli.js appear empty. Extract them as named definitions (command:build, route:GET /path, event:data) with kind 'function' so they slot into existing queries without changes.
1 parent 630fbab commit 2ac24ef

3 files changed

Lines changed: 398 additions & 0 deletions

File tree

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

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ fn walk_node(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
146146
symbols.calls.push(call_info);
147147
}
148148
}
149+
if let Some(cb_def) = extract_callback_definition(node, source) {
150+
symbols.definitions.push(cb_def);
151+
}
149152
}
150153

151154
"import_statement" => {
@@ -466,6 +469,126 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option<
466469
}
467470
}
468471

472+
fn find_anonymous_callback<'a>(args_node: &Node<'a>) -> Option<Node<'a>> {
473+
for i in 0..args_node.child_count() {
474+
if let Some(child) = args_node.child(i) {
475+
if child.kind() == "arrow_function" || child.kind() == "function_expression" {
476+
return Some(child);
477+
}
478+
}
479+
}
480+
None
481+
}
482+
483+
fn find_first_string_arg<'a>(args_node: &Node<'a>, source: &'a [u8]) -> Option<String> {
484+
for i in 0..args_node.child_count() {
485+
if let Some(child) = args_node.child(i) {
486+
if child.kind() == "string" {
487+
return Some(node_text(&child, source).replace(&['\'', '"'][..], ""));
488+
}
489+
}
490+
}
491+
None
492+
}
493+
494+
fn walk_call_chain<'a>(start_node: &Node<'a>, method_name: &str, source: &[u8]) -> Option<Node<'a>> {
495+
let mut current = Some(*start_node);
496+
while let Some(node) = current {
497+
if node.kind() == "call_expression" {
498+
if let Some(fn_node) = node.child_by_field_name("function") {
499+
if fn_node.kind() == "member_expression" {
500+
if let Some(prop) = fn_node.child_by_field_name("property") {
501+
if node_text(&prop, source) == method_name {
502+
return Some(node);
503+
}
504+
}
505+
}
506+
}
507+
}
508+
current = match node.kind() {
509+
"member_expression" => node.child_by_field_name("object"),
510+
"call_expression" => node.child_by_field_name("function"),
511+
_ => None,
512+
};
513+
}
514+
None
515+
}
516+
517+
fn is_express_method(method: &str) -> bool {
518+
matches!(
519+
method,
520+
"get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "all" | "use"
521+
)
522+
}
523+
524+
fn is_event_method(method: &str) -> bool {
525+
matches!(method, "on" | "once" | "addEventListener" | "addListener")
526+
}
527+
528+
fn extract_callback_definition(call_node: &Node, source: &[u8]) -> Option<Definition> {
529+
let fn_node = call_node.child_by_field_name("function")?;
530+
if fn_node.kind() != "member_expression" {
531+
return None;
532+
}
533+
534+
let prop = fn_node.child_by_field_name("property")?;
535+
let method = node_text(&prop, source);
536+
537+
let args = call_node
538+
.child_by_field_name("arguments")
539+
.or_else(|| find_child(call_node, "arguments"))?;
540+
541+
// Commander: .action(callback) with .command('name') in chain
542+
if method == "action" {
543+
let cb = find_anonymous_callback(&args)?;
544+
let obj = fn_node.child_by_field_name("object")?;
545+
let command_call = walk_call_chain(&obj, "command", source)?;
546+
let cmd_args = command_call
547+
.child_by_field_name("arguments")
548+
.or_else(|| find_child(&command_call, "arguments"))?;
549+
let cmd_name = find_first_string_arg(&cmd_args, source)?;
550+
let first_word = cmd_name.split_whitespace().next().unwrap_or(&cmd_name);
551+
return Some(Definition {
552+
name: format!("command:{}", first_word),
553+
kind: "function".to_string(),
554+
line: start_line(&cb),
555+
end_line: Some(end_line(&cb)),
556+
decorators: None,
557+
});
558+
}
559+
560+
// Express: app.get('/path', callback)
561+
if is_express_method(method) {
562+
let str_arg = find_first_string_arg(&args, source)?;
563+
if !str_arg.starts_with('/') {
564+
return None;
565+
}
566+
let cb = find_anonymous_callback(&args)?;
567+
return Some(Definition {
568+
name: format!("route:{} {}", method.to_uppercase(), str_arg),
569+
kind: "function".to_string(),
570+
line: start_line(&cb),
571+
end_line: Some(end_line(&cb)),
572+
decorators: None,
573+
});
574+
}
575+
576+
// Events: emitter.on('event', callback)
577+
if is_event_method(method) {
578+
let event_name = find_first_string_arg(&args, source)?;
579+
let cb = find_anonymous_callback(&args)?;
580+
return Some(Definition {
581+
name: format!("event:{}", event_name),
582+
kind: "function".to_string(),
583+
line: start_line(&cb),
584+
end_line: Some(end_line(&cb)),
585+
decorators: None,
586+
});
587+
}
588+
589+
None
590+
}
591+
469592
fn extract_superclass(heritage: &Node, source: &[u8]) -> Option<String> {
470593
for i in 0..heritage.child_count() {
471594
if let Some(child) = heritage.child(i) {
@@ -616,4 +739,70 @@ mod tests {
616739
assert_eq!(s.imports.len(), 1);
617740
assert_eq!(s.imports[0].wildcard_reexport, Some(true));
618741
}
742+
743+
#[test]
744+
fn extracts_commander_action_callback() {
745+
let s = parse_js("program.command('build [dir]').action(async (dir, opts) => { run(); });");
746+
let def = s.definitions.iter().find(|d| d.name == "command:build");
747+
assert!(def.is_some(), "should extract command:build definition");
748+
assert_eq!(def.unwrap().kind, "function");
749+
}
750+
751+
#[test]
752+
fn extracts_commander_query_command() {
753+
let s = parse_js("program.command('query <name>').action(() => { search(); });");
754+
let def = s.definitions.iter().find(|d| d.name == "command:query");
755+
assert!(def.is_some(), "should extract command:query definition");
756+
}
757+
758+
#[test]
759+
fn skips_commander_named_handler() {
760+
let s = parse_js("program.command('test').action(handleTest);");
761+
let defs: Vec<_> = s.definitions.iter().filter(|d| d.name.starts_with("command:")).collect();
762+
assert!(defs.is_empty(), "should not extract when handler is a named reference");
763+
}
764+
765+
#[test]
766+
fn extracts_express_get_route() {
767+
let s = parse_js("app.get('/api/users', (req, res) => { res.json([]); });");
768+
let def = s.definitions.iter().find(|d| d.name == "route:GET /api/users");
769+
assert!(def.is_some(), "should extract route:GET /api/users");
770+
assert_eq!(def.unwrap().kind, "function");
771+
}
772+
773+
#[test]
774+
fn extracts_express_post_route() {
775+
let s = parse_js("router.post('/api/items', async (req, res) => { save(); });");
776+
let def = s.definitions.iter().find(|d| d.name == "route:POST /api/items");
777+
assert!(def.is_some(), "should extract route:POST /api/items");
778+
}
779+
780+
#[test]
781+
fn skips_map_get_false_positive() {
782+
let s = parse_js("myMap.get('someKey');");
783+
let defs: Vec<_> = s.definitions.iter().filter(|d| d.name.starts_with("route:")).collect();
784+
assert!(defs.is_empty(), "should not extract Map.get as a route");
785+
}
786+
787+
#[test]
788+
fn extracts_event_on_callback() {
789+
let s = parse_js("emitter.on('data', (chunk) => { process(chunk); });");
790+
let def = s.definitions.iter().find(|d| d.name == "event:data");
791+
assert!(def.is_some(), "should extract event:data");
792+
assert_eq!(def.unwrap().kind, "function");
793+
}
794+
795+
#[test]
796+
fn extracts_event_once_callback() {
797+
let s = parse_js("server.once('listening', () => { log(); });");
798+
let def = s.definitions.iter().find(|d| d.name == "event:listening");
799+
assert!(def.is_some(), "should extract event:listening");
800+
}
801+
802+
#[test]
803+
fn skips_event_named_handler() {
804+
let s = parse_js("emitter.on('data', handleData);");
805+
let defs: Vec<_> = s.definitions.iter().filter(|d| d.name.starts_with("event:")).collect();
806+
assert!(defs.is_empty(), "should not extract when handler is a named reference");
807+
}
619808
}

src/extractors/javascript.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export function extractSymbols(tree, _filePath) {
140140
calls.push(callInfo);
141141
}
142142
}
143+
const cbDef = extractCallbackDefinition(node);
144+
if (cbDef) definitions.push(cbDef);
143145
break;
144146
}
145147

@@ -372,6 +374,126 @@ function extractCallInfo(fn, callNode) {
372374
return null;
373375
}
374376

377+
function findAnonymousCallback(argsNode) {
378+
for (let i = 0; i < argsNode.childCount; i++) {
379+
const child = argsNode.child(i);
380+
if (child && (child.type === 'arrow_function' || child.type === 'function_expression')) {
381+
return child;
382+
}
383+
}
384+
return null;
385+
}
386+
387+
function findFirstStringArg(argsNode) {
388+
for (let i = 0; i < argsNode.childCount; i++) {
389+
const child = argsNode.child(i);
390+
if (child && child.type === 'string') {
391+
return child.text.replace(/['"]/g, '');
392+
}
393+
}
394+
return null;
395+
}
396+
397+
function walkCallChain(startNode, methodName) {
398+
let current = startNode;
399+
while (current) {
400+
if (current.type === 'call_expression') {
401+
const fn = current.childForFieldName('function');
402+
if (fn && fn.type === 'member_expression') {
403+
const prop = fn.childForFieldName('property');
404+
if (prop && prop.text === methodName) {
405+
return current;
406+
}
407+
}
408+
}
409+
if (current.type === 'member_expression') {
410+
const obj = current.childForFieldName('object');
411+
current = obj;
412+
} else if (current.type === 'call_expression') {
413+
const fn = current.childForFieldName('function');
414+
current = fn;
415+
} else {
416+
break;
417+
}
418+
}
419+
return null;
420+
}
421+
422+
const EXPRESS_METHODS = new Set([
423+
'get',
424+
'post',
425+
'put',
426+
'delete',
427+
'patch',
428+
'options',
429+
'head',
430+
'all',
431+
'use',
432+
]);
433+
const EVENT_METHODS = new Set(['on', 'once', 'addEventListener', 'addListener']);
434+
435+
function extractCallbackDefinition(callNode) {
436+
const fn = callNode.childForFieldName('function');
437+
if (!fn || fn.type !== 'member_expression') return null;
438+
439+
const prop = fn.childForFieldName('property');
440+
if (!prop) return null;
441+
const method = prop.text;
442+
443+
const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
444+
if (!args) return null;
445+
446+
// Commander: .action(callback) with .command('name') in chain
447+
if (method === 'action') {
448+
const cb = findAnonymousCallback(args);
449+
if (!cb) return null;
450+
const commandCall = walkCallChain(fn.childForFieldName('object'), 'command');
451+
if (!commandCall) return null;
452+
const cmdArgs =
453+
commandCall.childForFieldName('arguments') || findChild(commandCall, 'arguments');
454+
if (!cmdArgs) return null;
455+
const cmdName = findFirstStringArg(cmdArgs);
456+
if (!cmdName) return null;
457+
const firstWord = cmdName.split(/\s/)[0];
458+
return {
459+
name: `command:${firstWord}`,
460+
kind: 'function',
461+
line: cb.startPosition.row + 1,
462+
endLine: nodeEndLine(cb),
463+
};
464+
}
465+
466+
// Express: app.get('/path', callback)
467+
if (EXPRESS_METHODS.has(method)) {
468+
const strArg = findFirstStringArg(args);
469+
if (!strArg || !strArg.startsWith('/')) return null;
470+
const cb = findAnonymousCallback(args);
471+
if (!cb) return null;
472+
return {
473+
name: `route:${method.toUpperCase()} ${strArg}`,
474+
kind: 'function',
475+
line: cb.startPosition.row + 1,
476+
endLine: nodeEndLine(cb),
477+
};
478+
}
479+
480+
// Events: emitter.on('event', callback)
481+
if (EVENT_METHODS.has(method)) {
482+
const eventName = findFirstStringArg(args);
483+
if (!eventName) return null;
484+
const cb = findAnonymousCallback(args);
485+
if (!cb) return null;
486+
return {
487+
name: `event:${eventName}`,
488+
kind: 'function',
489+
line: cb.startPosition.row + 1,
490+
endLine: nodeEndLine(cb),
491+
};
492+
}
493+
494+
return null;
495+
}
496+
375497
function extractSuperclass(heritage) {
376498
for (let i = 0; i < heritage.childCount; i++) {
377499
const child = heritage.child(i);

0 commit comments

Comments
 (0)