diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 913268d8..bfff34fb 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1202,6 +1202,16 @@ static const char *extract_route_path_from_args(CBMArena *a, TSNode args, const for (uint32_t ai = 0; ai < nc && ai < DECORATOR_SCAN_LIMIT; ai++) { TSNode arg = ts_node_named_child(args, ai); const char *ak = ts_node_type(arg); + /* Kotlin wraps each annotation argument in a `value_argument` node + * (and supports the named form `value = "/x"`); unwrap to the string. */ + if (strcmp(ak, "value_argument") == 0) { + TSNode s = cbm_find_child_by_kind(arg, "string_literal"); + if (ts_node_is_null(s)) { + continue; + } + arg = s; + ak = ts_node_type(arg); + } if (strcmp(ak, "string") != 0 && strcmp(ak, "string_literal") != 0 && strcmp(ak, "interpreted_string_literal") != 0) { continue; @@ -1252,14 +1262,63 @@ static bool try_route_from_decorator_call(CBMArena *a, TSNode dchild, const char return true; } -/* Try to extract a route from a Java/JVM annotation node (`annotation` or +/* Resolve an annotation's name node across grammars. Java exposes a `name` + * field; tree-sitter-kotlin does not — its annotation name lives in a nested + * type_identifier: + * @Foo -> (annotation (user_type (type_identifier))) + * @Foo("/x") -> (annotation (constructor_invocation (user_type (type_identifier)) + * (value_arguments ...))) + * Returns a null node when no name can be resolved. */ +static TSNode annotation_name_node(TSNode annotation) { + TSNode name = ts_node_child_by_field_name(annotation, TS_FIELD("name")); + if (!ts_node_is_null(name)) { + return name; + } + TSNode ut = cbm_find_child_by_kind(annotation, "user_type"); + if (ts_node_is_null(ut)) { + TSNode ci = cbm_find_child_by_kind(annotation, "constructor_invocation"); + if (!ts_node_is_null(ci)) { + ut = cbm_find_child_by_kind(ci, "user_type"); + } + } + if (!ts_node_is_null(ut)) { + TSNode ti = cbm_find_child_by_kind(ut, "type_identifier"); + if (ts_node_is_null(ti)) { + ti = cbm_find_child_by_kind(ut, "simple_identifier"); + } + return ti; + } + TSNode null_node = {0}; + return null_node; +} + +/* Resolve an annotation's argument list across grammars. Kotlin keeps the args + * under a `constructor_invocation` child as a `value_arguments` node rather than + * the `arguments` field / `argument_list` child that Java exposes. */ +static TSNode annotation_args_node(TSNode annotation) { + TSNode args = ts_node_child_by_field_name(annotation, TS_FIELD("arguments")); + if (!ts_node_is_null(args)) { + return args; + } + args = find_decorator_args(annotation); + if (!ts_node_is_null(args)) { + return args; + } + TSNode ci = cbm_find_child_by_kind(annotation, "constructor_invocation"); + if (!ts_node_is_null(ci)) { + return cbm_find_child_by_kind(ci, "value_arguments"); + } + return args; +} + +/* Try to extract a route from a Java/JVM/Kotlin annotation node (`annotation` or * `marker_annotation`). Spring mapping annotations carry the HTTP method in the * annotation name and the path in the (optional) argument list: * @GetMapping("/orders") @RequestMapping(value="/api") @PostMapping * Returns true when the annotation is a route-mapping annotation. */ static bool try_route_from_annotation(CBMArena *a, TSNode annotation, const char *source, const char **out_path, const char **out_method) { - TSNode name_node = ts_node_child_by_field_name(annotation, TS_FIELD("name")); + TSNode name_node = annotation_name_node(annotation); if (ts_node_is_null(name_node)) { return false; } @@ -1268,10 +1327,7 @@ static bool try_route_from_annotation(CBMArena *a, TSNode annotation, const char if (!method) { return false; } - TSNode args = ts_node_child_by_field_name(annotation, TS_FIELD("arguments")); - if (ts_node_is_null(args)) { - args = find_decorator_args(annotation); - } + TSNode args = annotation_args_node(annotation); const char *path = NULL; if (!ts_node_is_null(args)) { path = extract_route_path_from_args(a, args, source); diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index f871ace2..f6967943 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -283,6 +283,30 @@ TEST(handles_spring_java) { PASS(); } +/* Spring (Kotlin) — @RequestMapping/@GetMapping on a Kotlin @RestController. + * REAL BUG: tree-sitter-kotlin annotation nodes have no `name` field (the name + * lives in a nested user_type/type_identifier) and carry args under a + * constructor_invocation `value_arguments` node, so the Java-shaped + * ts_node_child_by_field_name(annotation, "name") / "arguments" lookups in + * try_route_from_annotation missed every Kotlin Spring route → route_path never + * set → no Route/HANDLES. Fixed by annotation_name_node/annotation_args_node. */ +TEST(handles_spring_kotlin) { + static const EtFile f[] = { + {"OrderController.kt", + "package com.example\n\n" + "import org.springframework.web.bind.annotation.RequestMapping\n" + "import org.springframework.web.bind.annotation.GetMapping\n\n" + "@RequestMapping(\"/api\")\nclass OrderController {\n" + " @GetMapping(\"/orders\")\n" + " fun listOrders(): String {\n" + " return \"orders\"\n }\n\n" + " @GetMapping(\"/orders/{id}\")\n" + " fun getOrder(id: Int): String {\n" + " return \"order:\" + id\n }\n}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 1)); + PASS(); +} + /* ASP.NET Minimal API (C#) — route registration via static MapGet/MapPost calls * with identifier handlers, under a Microsoft/AspNetCore path so the resolved * callee QN carries the "MapGet"/"Microsoft.AspNetCore" route-reg substrings. @@ -1351,6 +1375,7 @@ SUITE(edge_types_probe) { RUN_TEST(handles_fastify_js); RUN_TEST(handles_gin_go); RUN_TEST(handles_spring_java); + RUN_TEST(handles_spring_kotlin); RUN_TEST(handles_aspnet_csharp); RUN_TEST(handles_laravel_php); RUN_TEST(handles_rails_ruby);