From 7b528b3247257fdc8d503a4af617007bbe3f8454 Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Sat, 29 Aug 2026 13:53:42 -0400 Subject: [PATCH] fix(extraction): reach a Swift URL built by a constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift has no URL literal, so almost no real code passes a bare string to a request. It writes URL(string: "https://…")! instead, and the literal then sits two levels below the argument list: past the trailing "!", which the grammar models as a postfix_expression, and inside the constructor's own value_arguments. extract_url_or_topic_arg saw only the outer node and gave up, so the URL never reached the service-pattern table and no Route node formed. That is the shape issue #1892 reported from a real project — reaching a bare string argument was only the layer underneath it. swift_unwrap_url_constructor() steps past both wrappers. It unwraps only URL, URLComponents and URLRequest, so any other constructor keeps its own meaning and the outer call does not borrow the inner call's string. A non-literal argument such as URL(string: base + path) falls through to the ordinary handling unchanged. The value_argument unwrap added for the bare-string case is now swift_argument_value(), because the nested argument list needs the same step and the code was identical. Refs #1892 Signed-off-by: Joshua Richter --- internal/cbm/extract_calls.c | 70 +++++++++++++++++++++++++++++++----- tests/test_extraction.c | 49 +++++++++++++++++++++++++ tests/test_pipeline.c | 50 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f61b34be4..e7adca286 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2246,6 +2246,61 @@ static const char *extract_binary_concat_suffix(CBMExtractCtx *ctx, TSNode node) } // Try to extract URL/topic from a positional argument (string or constant). +/* Swift names its call arguments, so each one is a value_argument that may lead + * with a value_argument_label — the `from:` in `data(from: url)`. Return the + * value itself, and leave any other node exactly as it came in. */ +static TSNode swift_argument_value(TSNode arg) { + if (strcmp(ts_node_type(arg), "value_argument") != 0 || ts_node_named_child_count(arg) == 0) { + return arg; + } + 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); + } + return val; +} + +/* Swift has no URL literal, so almost no real code passes a bare string to a + * request. It writes `URL(string: "https://…")!` instead, and the literal then + * sits two levels down: past the trailing `!`, which the grammar models as a + * postfix_expression, and inside the constructor's own argument list. + * + * Unwrap both so that literal is as reachable as a bare one. Only the three + * Foundation types that take a URL string are unwrapped — any other call keeps + * its own meaning, and a non-literal argument such as `URL(string: base + path)` + * falls through to the ordinary handling unchanged. */ +static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) { + /* Step past a trailing "!" or "?". */ + if (strcmp(ts_node_type(arg), "postfix_expression") == 0) { + TSNode target = ts_node_child_by_field_name(arg, TS_FIELD("target")); + if (!ts_node_is_null(target)) { + arg = target; + } + } + if (strcmp(ts_node_type(arg), "call_expression") != 0) { + return arg; + } + TSNode callee = ts_node_named_child(arg, 0); + if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "simple_identifier") != 0) { + return arg; + } + const char *name = cbm_node_text(ctx->arena, callee, ctx->source); + if (!name || (strcmp(name, "URL") != 0 && strcmp(name, "URLComponents") != 0 && + strcmp(name, "URLRequest") != 0)) { + return arg; + } + TSNode suffix = cbm_find_child_by_kind(arg, "call_suffix"); + if (ts_node_is_null(suffix)) { + return arg; + } + TSNode inner = cbm_find_child_by_kind(suffix, "value_arguments"); + if (ts_node_is_null(inner) || ts_node_named_child_count(inner) == 0) { + return arg; + } + return swift_argument_value(ts_node_named_child(inner, 0)); +} + static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) { /* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the * client URL joins the server route's canonical placeholder (issue #1006). */ @@ -2289,15 +2344,12 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { } /* 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; + * rather than the value. */ + arg = swift_argument_value(arg); + /* A Swift URL is usually built rather than written bare, and the + * literal then sits inside that constructor. */ + if (ctx->language == CBM_LANG_SWIFT) { + arg = swift_unwrap_url_constructor(ctx, arg); } const char *ak = ts_node_type(arg); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 0af1a467c..8058e45a7 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4062,6 +4062,52 @@ TEST(swift_labeled_call_string_arg_issue1892) { PASS(); } +/* Swift has no URL literal, so real code builds one and force-unwraps it. The + * string then sits two levels below the argument list. */ +TEST(swift_nested_url_constructor_issue1892) { + CBMFileResult *r = extract("func fetch() { URLSession.shared.dataTask(with: URL(string: " + "\"https://example.com/api/v1/widgets\")!) }\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, "https://example.com/api/v1/widgets"); + cbm_free_result(r); + PASS(); +} + +/* Without the trailing "!" the constructor is not wrapped in a + * postfix_expression, so this covers the other shape. */ +TEST(swift_nested_url_no_bang_issue1892) { + CBMFileResult *r = + extract("func fetch() { client.send(to: URLRequest(url: \"/api/v1/widgets/1\")) }\n", + CBM_LANG_SWIFT, "t", "Send.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "client.send"); + 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(); +} + +/* A constructor that is not one of the three URL types keeps its own meaning: + * the outer call must not borrow the inner call's string. */ +TEST(swift_non_url_constructor_untouched_issue1892) { + CBMFileResult *r = extract("func f() { log.write(to: Formatter(pattern: \"%s-%d\")) }\n", + CBM_LANG_SWIFT, "t", "Log.swift"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "log.write"); + ASSERT_NOT_NULL(c); + ASSERT_NULL(c->first_string_arg); + 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 @@ -6818,6 +6864,9 @@ SUITE(extraction) { RUN_TEST(swift_chained_call); RUN_TEST(swift_force_unwrap_scanner_shift); RUN_TEST(swift_call_string_arg_issue1892); + RUN_TEST(swift_nested_url_constructor_issue1892); + RUN_TEST(swift_nested_url_no_bang_issue1892); + RUN_TEST(swift_non_url_constructor_untouched_issue1892); RUN_TEST(swift_labeled_call_string_arg_issue1892); RUN_TEST(objc_interface); RUN_TEST(objc_implementation); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 91b516677..2820e4cbf 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5485,6 +5485,55 @@ TEST(pipeline_native_fetch_classified_as_http_calls) { * 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. */ +/* The shape real Swift actually writes: the URL is built by a constructor and + * force-unwrapped, so the literal is two levels below the argument list. This + * is what issue #1892 reported from a real project. */ +TEST(pipeline_swift_nested_url_makes_route_issue1892) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_swiftnested_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "Sources/Client.swift", + "import Foundation\n" + "final class Client {\n" + " func listWidgets() {\n" + " URLSession.shared.dataTask(with: " + "URL(string: \"/api/v1/widgets\")!)\n" + " }\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/swiftnested.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); + + 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(); +} + TEST(pipeline_swift_http_call_makes_route_issue1892) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX"); @@ -13200,6 +13249,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_nested_url_makes_route_issue1892); 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);