Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -2257,6 +2257,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). */
Expand Down Expand Up @@ -2300,15 +2355,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);

Expand Down
49 changes: 49 additions & 0 deletions tests/test_extraction.c
Original file line number Diff line number Diff line change
Expand Up @@ -4186,6 +4186,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
Expand Down Expand Up @@ -7131,6 +7177,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);
Expand Down
50 changes: 50 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -5611,6 +5611,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");
Expand Down Expand Up @@ -13400,6 +13449,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);
Expand Down
Loading