From c4e902849f8062e37a0f0ddaed1c6cc042780cb3 Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Fri, 28 Aug 2026 21:46:32 -0400 Subject: [PATCH] fix(extraction): reach Swift call arguments through call_suffix handle_calls reads a call's arguments through one tree-sitter field lookup, ts_node_child_by_field_name(node, "arguments"). The vendored Swift grammar declares no "arguments" field at all -- its own ts_field_names[] table has zero occurrences, against one in Go and two in TypeScript. Swift models a call as a target expression plus a call_suffix, and the arguments hang off the suffix as value_arguments. So args was null for every Swift call ever parsed, first_string_arg was never populated, and no Swift HTTP call could raise an HTTP_CALLS edge or a Route node. Alamofire, Moya and URLSession have been in the service-pattern table the whole time and match the callee text correctly; the URL simply never arrived. Three changes, all in extract_calls.c: - swift_call_args() reaches the argument list through call_suffix, in the same shape as the existing objectscript_call_args() fallback and used from the same place. A trailing closure has no value_arguments, so it returns a null node and that call behaves as before. - extract_url_or_topic_arg() unwraps Swift's per-argument value_argument node, stepping past a leading value_argument_label. Without it, dataTask(with: "/api/v1/widgets") yields the label "with" rather than the path. PHP and C# already had the same unwrap for their own "argument" node. - is_string_like() gains line_string_literal, which is what Swift calls an ordinary "..." literal. The list already held raw_string_literal, so only Swift's common case was missing. This is the layer underneath #1892 rather than the whole of it. A literal URL argument now arrives; a literal nested inside a constructor, as in URLSession.shared.data(from: URL(string: "...")!), still does not, because extract_positional_url reads a literal, a template string, a concatenation or a named constant and that shape is none of them. Reproduce-first: all three tests fail without the fix, two on a null first_string_arg and one on HTTP_CALLS being 0. Fixes #1892 Signed-off-by: Joshua Richter --- internal/cbm/extract_calls.c | 32 ++++++++++++++++++++- tests/test_extraction.c | 37 ++++++++++++++++++++++++ tests/test_pipeline.c | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index c190e36b7..f61b34be4 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -61,7 +61,8 @@ static const char *lookup_url_builder(const CBMExtractCtx *ctx, const char *name static int is_string_like(const char *kind) { return (strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 || strcmp(kind, "interpreted_string_literal") == 0 || - strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0); + strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0 || + strcmp(kind, "line_string_literal") == 0); } /* Strip surrounding quotes from a string, return arena-allocated copy */ @@ -2286,6 +2287,18 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) { arg = ts_node_named_child(arg, 0); } + /* Swift wraps each argument in a value_argument that may lead with its + * label, so `data(from: url)` would otherwise yield the label `from` + * rather than the value. Step past a leading value_argument_label. */ + if (strcmp(ts_node_type(arg), "value_argument") == 0 && + ts_node_named_child_count(arg) > 0) { + TSNode val = ts_node_named_child(arg, 0); + if (strcmp(ts_node_type(val), "value_argument_label") == 0 && + ts_node_named_child_count(arg) > 1) { + val = ts_node_named_child(arg, 1); + } + arg = val; + } const char *ak = ts_node_type(arg); if (strcmp(ak, "keyword_argument") == 0 || strcmp(ak, "pair") == 0) { @@ -3045,6 +3058,19 @@ static TSNode objectscript_call_args(TSNode node) { : cbm_find_child_by_kind(macro_function, "method_args"); } +/* Swift models a call as a target expression plus a call_suffix, and its grammar + * declares no "arguments" field at all, so the generic field lookup finds + * nothing for every Swift call. Reach the argument list through the suffix + * instead. A trailing closure has a call_suffix with no value_arguments, which + * returns a null node and leaves the call without a string argument, as before. */ +static TSNode swift_call_args(TSNode node) { + TSNode suffix = cbm_find_child_by_kind(node, "call_suffix"); + if (ts_node_is_null(suffix)) { + return (TSNode){0}; + } + return cbm_find_child_by_kind(suffix, "value_arguments"); +} + static bool node_has_token(TSNode node, const char *token) { uint32_t count = ts_node_child_count(node); for (uint32_t i = 0; i < count; i++) { @@ -3634,6 +3660,10 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML if (ts_node_is_null(args) && is_objectscript_language(ctx->language)) { args = objectscript_call_args(node); } + // Swift has no "arguments" field either; its args hang off call_suffix. + if (ts_node_is_null(args) && ctx->language == CBM_LANG_SWIFT) { + args = swift_call_args(node); + } if (!ts_node_is_null(args)) { call.first_string_arg = extract_url_or_topic_arg(ctx, args); /* #952: routes registered inside Laravel `prefix()->group()` diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5f3c9dd80..0af1a467c 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4027,6 +4027,41 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee) return NULL; } +/* #1892: the Swift grammar declares no "arguments" field, so the generic field + * lookup read nothing and every Swift call lost its arguments. Without the URL + * the service-pattern table cannot raise an HTTP_CALLS edge or a Route node, + * even though Alamofire/Moya/URLSession are already listed in it. */ +TEST(swift_call_string_arg_issue1892) { + CBMFileResult *r = + extract("func listWidgets() { AF.request(\"https://example.com/api/v1/widgets\") }\n", + CBM_LANG_SWIFT, "t", "Client.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "AF.request"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets"); + cbm_free_result(r); + PASS(); +} + +/* Swift labels its arguments, and each one sits in a value_argument node that + * leads with the label. Reading the first child alone would return `with` + * rather than the path. */ +TEST(swift_labeled_call_string_arg_issue1892) { + CBMFileResult *r = + extract("func fetch() { URLSession.shared.dataTask(with: \"/api/v1/widgets/1\") }\n", + CBM_LANG_SWIFT, "t", "Fetch.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1"); + cbm_free_result(r); + PASS(); +} + /* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped * literal, consumed as client(buildPath(id)). The builder's URL is recorded in * the per-file constant map and resolved at the call site, for both return @@ -6782,6 +6817,8 @@ SUITE(extraction) { RUN_TEST(swift_constructor_call); RUN_TEST(swift_chained_call); RUN_TEST(swift_force_unwrap_scanner_shift); + RUN_TEST(swift_call_string_arg_issue1892); + RUN_TEST(swift_labeled_call_string_arg_issue1892); RUN_TEST(objc_interface); RUN_TEST(objc_implementation); RUN_TEST(dart_top_level_function); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2af0366f8..91b516677 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5480,6 +5480,61 @@ TEST(pipeline_native_fetch_classified_as_http_calls) { PASS(); } +/* #1892: Swift produced no Route node and no HTTP_CALLS edge, because the + * Swift grammar has no "arguments" field and the generic lookup therefore read + * no call arguments at all. Alamofire/URLSession were already in the service + * pattern table; the URL simply never reached it. This is the Swift twin of + * the TypeScript fetch case above. */ +TEST(pipeline_swift_http_call_makes_route_issue1892) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* URLSession, not Alamofire's `AF` shorthand: the service pattern table + * matches the library name in the callee text, and "AF.request" contains + * no such name. */ + write_temp_file(tmp, "Sources/Client.swift", + "import Foundation\n" + "final class Client {\n" + " func listWidgets() {\n" + " URLSession.shared.dataTask(with: \"/api/v1/widgets\")\n" + " }\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/swifthttp.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1); + + /* The edge carries the URL, so pass_route_nodes can mint the Route the + * cross-repo matcher joins a server route against. */ + cbm_node_t *routes = NULL; + int route_count = 0; + cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count); + int widget_routes = 0; + for (int i = 0; i < route_count; i++) { + if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) { + widget_routes++; + } + } + cbm_store_free_nodes(routes, route_count); + ASSERT_GTE(widget_routes, 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's * resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls * but forces the parallel resolver, since the empty-resolution fallback is a @@ -13145,6 +13200,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); + RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); /* Git history pass */