diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index c93e46a44..085868bdb 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1668,11 +1668,15 @@ static char *extract_callee_name(CBMArena *a, TSNode node, const char *source, C // method is `new`. The constructor body lives in `initialize`, so a callee // of "new" never resolves. Redirect to the receiver type name so the call // links to the class/constructor like every other language's `new T()`. + // Scope-resolved receivers (`Admin::User.new`) get the same treatment — + // the callee carries the full constant path ("Admin::User"), whose bare + // leaf ("User") is what resolution joins on. if (lang == CBM_LANG_RUBY) { TSNode m = ts_node_child_by_field_name(node, TS_FIELD("method")); TSNode recv = ts_node_child_by_field_name(node, TS_FIELD("receiver")); if (!ts_node_is_null(m) && !ts_node_is_null(recv) && - strcmp(ts_node_type(recv), "constant") == 0) { + (strcmp(ts_node_type(recv), "constant") == 0 || + strcmp(ts_node_type(recv), "scope_resolution") == 0)) { char *mt = cbm_node_text(a, m, source); if (mt && strcmp(mt, "new") == 0) { char *rt = cbm_node_text(a, recv, source); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 447e7c104..9f5109f13 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -619,6 +619,57 @@ TEST(ruby_module) { PASS(); } +/* A Ruby `Const.new` call is rewritten to name the CONSTANT, not the method: + * the constructor body lives in `initialize`, so the call should link the class + * like every other language's `new T()`. The rewrite fired only for bare + * `constant` receivers, so a scope-resolved receiver kept the whole call text + * as its callee ("Admin::User.new"), which resolves to nothing and cannot join + * the Ruby LSP's constructor rows, whose callee is the class QN. + * + * Both spellings must land on the bare constant path; a retained ".new" suffix + * is the bug's signature. */ +TEST(ruby_constructor_callee_is_constant_path) { + CBMFileResult *r = extract("module Admin\n class User\n end\nend\n" + "class Widget\nend\n" + "class Maker\n" + " def build\n" + " Admin::User.new\n" + " Widget.new\n" + " end\n" + "end\n", + CBM_LANG_RUBY, "t", "maker.rb"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + int scoped = 0, bare = 0, kept_new = 0; + for (int i = 0; i < r->calls.count; i++) { + const char *callee = r->calls.items[i].callee_name; + if (strcmp(callee, "Admin::User") == 0) + scoped = 1; + if (strcmp(callee, "Widget") == 0) + bare = 1; + /* Either failure mode: the method name survived on its own, or the + * whole `Recv.new` expression did. */ + size_t n = strlen(callee); + if (strcmp(callee, "new") == 0 || (n >= 4 && strcmp(callee + n - 4, ".new") == 0)) + kept_new = 1; + } + if (!scoped || !bare || kept_new) { + printf(" ruby callees (%d):\n", r->calls.count); + for (int i = 0; i < r->calls.count; i++) + printf(" '%s'\n", r->calls.items[i].callee_name); + } + /* Scope-resolved receiver reduces to the constant path; its bare leaf is + * what resolution joins on. */ + ASSERT(scoped); + /* Bare receiver: pre-existing behaviour, guarded against regression. */ + ASSERT(bare); + ASSERT_FALSE(kept_new); + + cbm_free_result(r); + PASS(); +} + /* --- C# --- */ TEST(csharp_class) { CBMFileResult *r = extract("namespace App { public class Service { public void Run() {} public " @@ -5396,6 +5447,7 @@ SUITE(extraction) { RUN_TEST(php_function); RUN_TEST(ruby_class); RUN_TEST(ruby_module); + RUN_TEST(ruby_constructor_callee_is_constant_path); RUN_TEST(csharp_class); RUN_TEST(csharp_interface); RUN_TEST(swift_class);