Skip to content

Commit 9e1dc6d

Browse files
author
Your Name
committed
feat(extraction): C# delegate/event handler call resolution
Three fixes for C# delegate and event subscription patterns that were invisible to the call graph: Fix 1 — Bare method reference subscription: event += MethodName creates a CALLS edge from the subscribing method to the handler. Detects assignment_expression with += operator where the RHS is an identifier or member_access_expression. e.g. socket.OnConnected += SocketOnConnected Fix 2 — Delegate .Invoke() resolution: delegate?.Invoke(args) resolved to 'Invoke' which matches nothing. Now detects conditional_access_expression and member_access_expression where the method is 'Invoke', extracts the receiver (delegate property) name as the call target instead. e.g. OnConnected?.Invoke(this, e) → CALLS edge to 'OnConnected' Fix 3 — Lambda event body scope attribution: Lambda expressions inside += assignments no longer create a new scope boundary. Calls inside the lambda body are attributed to the enclosing method that subscribes the event, not to an anonymous lambda scope. This means all handler logic is correctly attributed to the method that registers the event subscription. e.g. socket.OnError += (s, e) => { ErrorOnce(...); } attributes the ErrorOnce call to the method containing the += statement. Tested on C# codebase: SocketOnConnected gained 1 incoming caller (from += subscription) and 1 outgoing call (from ?.Invoke resolution). InitializeExternalClient gained 10 additional outgoing calls from lambda body attribution (30 total, up from 20).
1 parent 93041d2 commit 9e1dc6d

2 files changed

Lines changed: 123 additions & 3 deletions

File tree

internal/cbm/extract_calls.c

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,4 +344,101 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk
344344
}
345345
}
346346
}
347+
348+
// C# delegate/event patterns
349+
if (ctx->language == CBM_LANG_CSHARP) {
350+
// Fix 1: event += MethodName (bare method reference subscription)
351+
// Creates a CALLS edge from the subscribing method to the handler method.
352+
// e.g. _socket.OnConnected += SocketOnConnected;
353+
if (strcmp(kind, "assignment_expression") == 0) {
354+
TSNode op = ts_node_child_by_field_name(node, "operator", 8);
355+
if (!ts_node_is_null(op)) {
356+
char *op_text = cbm_node_text(ctx->arena, op, ctx->source);
357+
if (op_text && strcmp(op_text, "+=") == 0) {
358+
TSNode right = ts_node_child_by_field_name(node, "right", 5);
359+
if (!ts_node_is_null(right)) {
360+
const char *rk = ts_node_type(right);
361+
if (strcmp(rk, "identifier") == 0 ||
362+
strcmp(rk, "member_access_expression") == 0) {
363+
char *callee = cbm_node_text(ctx->arena, right, ctx->source);
364+
if (callee && callee[0] && !cbm_is_keyword(callee, ctx->language)) {
365+
CBMCall call;
366+
call.callee_name = callee;
367+
call.enclosing_func_qn = state->enclosing_func_qn;
368+
cbm_calls_push(&ctx->result->calls, ctx->arena, call);
369+
}
370+
}
371+
}
372+
}
373+
}
374+
}
375+
376+
// Fix 2: delegate?.Invoke() → resolve to receiver (delegate) name.
377+
// C# delegates are invoked via .Invoke() or ?.Invoke() — the callee name
378+
// "Invoke" resolves to nothing. Instead, extract the receiver (delegate property)
379+
// name, which is more likely to match a registered symbol.
380+
// e.g. OnConnected?.Invoke(this, e) → creates CALLS edge to "OnConnected"
381+
//
382+
// C# tree-sitter AST for "OnConnected?.Invoke(this, e)":
383+
// invocation_expression
384+
// function: conditional_access_expression
385+
// expression: identifier "OnConnected" ← receiver
386+
// member_binding_expression
387+
// name: identifier "Invoke" ← method
388+
// arguments: argument_list
389+
if (cbm_kind_in_set(node, spec->call_node_types)) {
390+
TSNode func_node2 = ts_node_child_by_field_name(node, "function", 8);
391+
if (!ts_node_is_null(func_node2)) {
392+
const char *fk2 = ts_node_type(func_node2);
393+
bool is_invoke = false;
394+
TSNode receiver2 = {0}; // NOLINT
395+
396+
if (strcmp(fk2, "conditional_access_expression") == 0) {
397+
// ?. access: look for member_binding_expression child
398+
uint32_t ncc = ts_node_named_child_count(func_node2);
399+
for (uint32_t ci = 0; ci < ncc; ci++) {
400+
TSNode child = ts_node_named_child(func_node2, ci);
401+
const char *ck = ts_node_type(child);
402+
if (strcmp(ck, "member_binding_expression") == 0) {
403+
TSNode name_n = ts_node_child_by_field_name(child, "name", 4);
404+
if (!ts_node_is_null(name_n)) {
405+
char *nm = cbm_node_text(ctx->arena, name_n, ctx->source);
406+
if (nm && strcmp(nm, "Invoke") == 0) {
407+
is_invoke = true;
408+
}
409+
}
410+
}
411+
if (strcmp(ck, "identifier") == 0 ||
412+
strcmp(ck, "member_access_expression") == 0) {
413+
receiver2 = child;
414+
}
415+
}
416+
} else if (strcmp(fk2, "member_access_expression") == 0) {
417+
// Dot access: obj.Invoke(...)
418+
TSNode name_n = ts_node_child_by_field_name(func_node2, "name", 4);
419+
if (!ts_node_is_null(name_n)) {
420+
char *nm = cbm_node_text(ctx->arena, name_n, ctx->source);
421+
if (nm && strcmp(nm, "Invoke") == 0) {
422+
is_invoke = true;
423+
TSNode expr = ts_node_child_by_field_name(func_node2,
424+
"expression", 10);
425+
if (!ts_node_is_null(expr)) {
426+
receiver2 = expr;
427+
}
428+
}
429+
}
430+
}
431+
432+
if (is_invoke && !ts_node_is_null(receiver2)) {
433+
char *recv = cbm_node_text(ctx->arena, receiver2, ctx->source);
434+
if (recv && recv[0] && !cbm_is_keyword(recv, ctx->language)) {
435+
CBMCall call;
436+
call.callee_name = recv;
437+
call.enclosing_func_qn = state->enclosing_func_qn;
438+
cbm_calls_push(&ctx->result->calls, ctx->arena, call);
439+
}
440+
}
441+
}
442+
}
443+
}
347444
}

internal/cbm/extract_unified.c

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,32 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
153153

154154
// 4. Push scope markers for boundary nodes
155155
if (spec->function_node_types && cbm_kind_in_set(node, spec->function_node_types)) {
156-
const char *fqn = compute_func_qn(ctx, node, spec, &state);
157-
if (fqn) {
158-
push_scope(&state, SCOPE_FUNC, depth, fqn);
156+
// Fix 3: C# lambda_expression inside += assignment should NOT create
157+
// a new scope boundary. Calls inside the lambda body should be attributed
158+
// to the outer method that subscribes the event handler, not to an
159+
// anonymous lambda. This matches the semantic intent: the subscribing
160+
// method IS responsible for what runs when the event fires.
161+
bool skip_scope = false;
162+
if (ctx->language == CBM_LANG_CSHARP &&
163+
strcmp(ts_node_type(node), "lambda_expression") == 0) {
164+
TSNode parent = ts_node_parent(node);
165+
if (!ts_node_is_null(parent) &&
166+
strcmp(ts_node_type(parent), "assignment_expression") == 0) {
167+
TSNode op = ts_node_child_by_field_name(parent, "operator", 8);
168+
if (!ts_node_is_null(op)) {
169+
char *op_text = cbm_node_text(ctx->arena, op, ctx->source);
170+
if (op_text && (strcmp(op_text, "+=") == 0 ||
171+
strcmp(op_text, "-=") == 0)) {
172+
skip_scope = true;
173+
}
174+
}
175+
}
176+
}
177+
if (!skip_scope) {
178+
const char *fqn = compute_func_qn(ctx, node, spec, &state);
179+
if (fqn) {
180+
push_scope(&state, SCOPE_FUNC, depth, fqn);
181+
}
159182
}
160183
} else if (spec->class_node_types && cbm_kind_in_set(node, spec->class_node_types)) {
161184
const char *cqn = compute_class_qn(ctx, node);

0 commit comments

Comments
 (0)