Skip to content

Commit 1f5ecb5

Browse files
fix(extraction): reach a Swift URL built by a constructor
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 <jrichter5781@gmail.com>
1 parent acc403a commit 1f5ecb5

3 files changed

Lines changed: 160 additions & 9 deletions

File tree

internal/cbm/extract_calls.c

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,61 @@ static const char *extract_binary_concat_suffix(CBMExtractCtx *ctx, TSNode node)
20832083
}
20842084

20852085
// Try to extract URL/topic from a positional argument (string or constant).
2086+
/* Swift names its call arguments, so each one is a value_argument that may lead
2087+
* with a value_argument_label — the `from:` in `data(from: url)`. Return the
2088+
* value itself, and leave any other node exactly as it came in. */
2089+
static TSNode swift_argument_value(TSNode arg) {
2090+
if (strcmp(ts_node_type(arg), "value_argument") != 0 || ts_node_named_child_count(arg) == 0) {
2091+
return arg;
2092+
}
2093+
TSNode val = ts_node_named_child(arg, 0);
2094+
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2095+
ts_node_named_child_count(arg) > 1) {
2096+
val = ts_node_named_child(arg, 1);
2097+
}
2098+
return val;
2099+
}
2100+
2101+
/* Swift has no URL literal, so almost no real code passes a bare string to a
2102+
* request. It writes `URL(string: "https://…")!` instead, and the literal then
2103+
* sits two levels down: past the trailing `!`, which the grammar models as a
2104+
* postfix_expression, and inside the constructor's own argument list.
2105+
*
2106+
* Unwrap both so that literal is as reachable as a bare one. Only the three
2107+
* Foundation types that take a URL string are unwrapped — any other call keeps
2108+
* its own meaning, and a non-literal argument such as `URL(string: base + path)`
2109+
* falls through to the ordinary handling unchanged. */
2110+
static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) {
2111+
/* Step past a trailing "!" or "?". */
2112+
if (strcmp(ts_node_type(arg), "postfix_expression") == 0) {
2113+
TSNode target = ts_node_child_by_field_name(arg, TS_FIELD("target"));
2114+
if (!ts_node_is_null(target)) {
2115+
arg = target;
2116+
}
2117+
}
2118+
if (strcmp(ts_node_type(arg), "call_expression") != 0) {
2119+
return arg;
2120+
}
2121+
TSNode callee = ts_node_named_child(arg, 0);
2122+
if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "simple_identifier") != 0) {
2123+
return arg;
2124+
}
2125+
const char *name = cbm_node_text(ctx->arena, callee, ctx->source);
2126+
if (!name || (strcmp(name, "URL") != 0 && strcmp(name, "URLComponents") != 0 &&
2127+
strcmp(name, "URLRequest") != 0)) {
2128+
return arg;
2129+
}
2130+
TSNode suffix = cbm_find_child_by_kind(arg, "call_suffix");
2131+
if (ts_node_is_null(suffix)) {
2132+
return arg;
2133+
}
2134+
TSNode inner = cbm_find_child_by_kind(suffix, "value_arguments");
2135+
if (ts_node_is_null(inner) || ts_node_named_child_count(inner) == 0) {
2136+
return arg;
2137+
}
2138+
return swift_argument_value(ts_node_named_child(inner, 0));
2139+
}
2140+
20862141
static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) {
20872142
/* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the
20882143
* client URL joins the server route's canonical placeholder (issue #1006). */
@@ -2126,15 +2181,12 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
21262181
}
21272182
/* Swift wraps each argument in a value_argument that may lead with its
21282183
* label, so `data(from: url)` would otherwise yield the label `from`
2129-
* rather than the value. Step past a leading value_argument_label. */
2130-
if (strcmp(ts_node_type(arg), "value_argument") == 0 &&
2131-
ts_node_named_child_count(arg) > 0) {
2132-
TSNode val = ts_node_named_child(arg, 0);
2133-
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2134-
ts_node_named_child_count(arg) > 1) {
2135-
val = ts_node_named_child(arg, 1);
2136-
}
2137-
arg = val;
2184+
* rather than the value. */
2185+
arg = swift_argument_value(arg);
2186+
/* A Swift URL is usually built rather than written bare, and the
2187+
* literal then sits inside that constructor. */
2188+
if (ctx->language == CBM_LANG_SWIFT) {
2189+
arg = swift_unwrap_url_constructor(ctx, arg);
21382190
}
21392191
const char *ak = ts_node_type(arg);
21402192

tests/test_extraction.c

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3772,6 +3772,52 @@ TEST(swift_labeled_call_string_arg_issue1892) {
37723772
PASS();
37733773
}
37743774

3775+
/* Swift has no URL literal, so real code builds one and force-unwraps it. The
3776+
* string then sits two levels below the argument list. */
3777+
TEST(swift_nested_url_constructor_issue1892) {
3778+
CBMFileResult *r = extract("func fetch() { URLSession.shared.dataTask(with: URL(string: "
3779+
"\"https://example.com/api/v1/widgets\")!) }\n",
3780+
CBM_LANG_SWIFT, "t", "Fetch.swift");
3781+
ASSERT_NOT_NULL(r);
3782+
ASSERT_FALSE(r->has_error);
3783+
const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask");
3784+
ASSERT_NOT_NULL(c);
3785+
ASSERT_NOT_NULL(c->first_string_arg);
3786+
ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets");
3787+
cbm_free_result(r);
3788+
PASS();
3789+
}
3790+
3791+
/* Without the trailing "!" the constructor is not wrapped in a
3792+
* postfix_expression, so this covers the other shape. */
3793+
TEST(swift_nested_url_no_bang_issue1892) {
3794+
CBMFileResult *r =
3795+
extract("func fetch() { client.send(to: URLRequest(url: \"/api/v1/widgets/1\")) }\n",
3796+
CBM_LANG_SWIFT, "t", "Send.swift");
3797+
ASSERT_NOT_NULL(r);
3798+
ASSERT_FALSE(r->has_error);
3799+
const CBMCall *c = find_call_by_callee(r, "client.send");
3800+
ASSERT_NOT_NULL(c);
3801+
ASSERT_NOT_NULL(c->first_string_arg);
3802+
ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1");
3803+
cbm_free_result(r);
3804+
PASS();
3805+
}
3806+
3807+
/* A constructor that is not one of the three URL types keeps its own meaning:
3808+
* the outer call must not borrow the inner call's string. */
3809+
TEST(swift_non_url_constructor_untouched_issue1892) {
3810+
CBMFileResult *r = extract("func f() { log.write(to: Formatter(pattern: \"%s-%d\")) }\n",
3811+
CBM_LANG_SWIFT, "t", "Log.swift");
3812+
ASSERT_NOT_NULL(r);
3813+
ASSERT_FALSE(r->has_error);
3814+
const CBMCall *c = find_call_by_callee(r, "log.write");
3815+
ASSERT_NOT_NULL(c);
3816+
ASSERT_NULL(c->first_string_arg);
3817+
cbm_free_result(r);
3818+
PASS();
3819+
}
3820+
37753821
/* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped
37763822
* literal, consumed as client(buildPath(id)). The builder's URL is recorded in
37773823
* the per-file constant map and resolved at the call site, for both return
@@ -6216,6 +6262,9 @@ SUITE(extraction) {
62166262
RUN_TEST(swift_constructor_call);
62176263
RUN_TEST(swift_chained_call);
62186264
RUN_TEST(swift_call_string_arg_issue1892);
6265+
RUN_TEST(swift_nested_url_constructor_issue1892);
6266+
RUN_TEST(swift_nested_url_no_bang_issue1892);
6267+
RUN_TEST(swift_non_url_constructor_untouched_issue1892);
62196268
RUN_TEST(swift_labeled_call_string_arg_issue1892);
62206269
RUN_TEST(objc_interface);
62216270
RUN_TEST(objc_implementation);

tests/test_pipeline.c

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4997,6 +4997,55 @@ TEST(pipeline_native_fetch_classified_as_http_calls) {
49974997
* no call arguments at all. Alamofire/URLSession were already in the service
49984998
* pattern table; the URL simply never reached it. This is the Swift twin of
49994999
* the TypeScript fetch case above. */
5000+
/* The shape real Swift actually writes: the URL is built by a constructor and
5001+
* force-unwrapped, so the literal is two levels below the argument list. This
5002+
* is what issue #1892 reported from a real project. */
5003+
TEST(pipeline_swift_nested_url_makes_route_issue1892) {
5004+
char tmp[256];
5005+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swiftnested_XXXXXX");
5006+
if (!cbm_mkdtemp(tmp)) {
5007+
FAIL("tmpdir");
5008+
}
5009+
5010+
write_temp_file(tmp, "Sources/Client.swift",
5011+
"import Foundation\n"
5012+
"final class Client {\n"
5013+
" func listWidgets() {\n"
5014+
" URLSession.shared.dataTask(with: "
5015+
"URL(string: \"/api/v1/widgets\")!)\n"
5016+
" }\n"
5017+
"}\n");
5018+
5019+
char db_path[512];
5020+
snprintf(db_path, sizeof(db_path), "%s/swiftnested.db", tmp);
5021+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
5022+
ASSERT_NOT_NULL(p);
5023+
ASSERT_EQ(cbm_pipeline_run(p), 0);
5024+
const char *project = cbm_pipeline_project_name(p);
5025+
5026+
cbm_store_t *s = cbm_store_open_path(db_path);
5027+
ASSERT_NOT_NULL(s);
5028+
5029+
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
5030+
5031+
cbm_node_t *routes = NULL;
5032+
int route_count = 0;
5033+
cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count);
5034+
int widget_routes = 0;
5035+
for (int i = 0; i < route_count; i++) {
5036+
if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) {
5037+
widget_routes++;
5038+
}
5039+
}
5040+
cbm_store_free_nodes(routes, route_count);
5041+
ASSERT_GTE(widget_routes, 1);
5042+
5043+
cbm_store_close(s);
5044+
cbm_pipeline_free(p);
5045+
th_rmtree(tmp);
5046+
PASS();
5047+
}
5048+
50005049
TEST(pipeline_swift_http_call_makes_route_issue1892) {
50015050
char tmp[256];
50025051
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX");
@@ -12569,6 +12618,7 @@ SUITE(pipeline) {
1256912618
RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);
1257012619
RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier);
1257112620
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
12621+
RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892);
1257212622
RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);
1257312623
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
1257412624
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);

0 commit comments

Comments
 (0)