Skip to content

Commit eb80a4e

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 7b0f553 commit eb80a4e

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
@@ -2257,6 +2257,61 @@ static const char *extract_binary_concat_suffix(CBMExtractCtx *ctx, TSNode node)
22572257
}
22582258

22592259
// Try to extract URL/topic from a positional argument (string or constant).
2260+
/* Swift names its call arguments, so each one is a value_argument that may lead
2261+
* with a value_argument_label — the `from:` in `data(from: url)`. Return the
2262+
* value itself, and leave any other node exactly as it came in. */
2263+
static TSNode swift_argument_value(TSNode arg) {
2264+
if (strcmp(ts_node_type(arg), "value_argument") != 0 || ts_node_named_child_count(arg) == 0) {
2265+
return arg;
2266+
}
2267+
TSNode val = ts_node_named_child(arg, 0);
2268+
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2269+
ts_node_named_child_count(arg) > 1) {
2270+
val = ts_node_named_child(arg, 1);
2271+
}
2272+
return val;
2273+
}
2274+
2275+
/* Swift has no URL literal, so almost no real code passes a bare string to a
2276+
* request. It writes `URL(string: "https://…")!` instead, and the literal then
2277+
* sits two levels down: past the trailing `!`, which the grammar models as a
2278+
* postfix_expression, and inside the constructor's own argument list.
2279+
*
2280+
* Unwrap both so that literal is as reachable as a bare one. Only the three
2281+
* Foundation types that take a URL string are unwrapped — any other call keeps
2282+
* its own meaning, and a non-literal argument such as `URL(string: base + path)`
2283+
* falls through to the ordinary handling unchanged. */
2284+
static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) {
2285+
/* Step past a trailing "!" or "?". */
2286+
if (strcmp(ts_node_type(arg), "postfix_expression") == 0) {
2287+
TSNode target = ts_node_child_by_field_name(arg, TS_FIELD("target"));
2288+
if (!ts_node_is_null(target)) {
2289+
arg = target;
2290+
}
2291+
}
2292+
if (strcmp(ts_node_type(arg), "call_expression") != 0) {
2293+
return arg;
2294+
}
2295+
TSNode callee = ts_node_named_child(arg, 0);
2296+
if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "simple_identifier") != 0) {
2297+
return arg;
2298+
}
2299+
const char *name = cbm_node_text(ctx->arena, callee, ctx->source);
2300+
if (!name || (strcmp(name, "URL") != 0 && strcmp(name, "URLComponents") != 0 &&
2301+
strcmp(name, "URLRequest") != 0)) {
2302+
return arg;
2303+
}
2304+
TSNode suffix = cbm_find_child_by_kind(arg, "call_suffix");
2305+
if (ts_node_is_null(suffix)) {
2306+
return arg;
2307+
}
2308+
TSNode inner = cbm_find_child_by_kind(suffix, "value_arguments");
2309+
if (ts_node_is_null(inner) || ts_node_named_child_count(inner) == 0) {
2310+
return arg;
2311+
}
2312+
return swift_argument_value(ts_node_named_child(inner, 0));
2313+
}
2314+
22602315
static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) {
22612316
/* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the
22622317
* client URL joins the server route's canonical placeholder (issue #1006). */
@@ -2300,15 +2355,12 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
23002355
}
23012356
/* Swift wraps each argument in a value_argument that may lead with its
23022357
* label, so `data(from: url)` would otherwise yield the label `from`
2303-
* rather than the value. Step past a leading value_argument_label. */
2304-
if (strcmp(ts_node_type(arg), "value_argument") == 0 &&
2305-
ts_node_named_child_count(arg) > 0) {
2306-
TSNode val = ts_node_named_child(arg, 0);
2307-
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2308-
ts_node_named_child_count(arg) > 1) {
2309-
val = ts_node_named_child(arg, 1);
2310-
}
2311-
arg = val;
2358+
* rather than the value. */
2359+
arg = swift_argument_value(arg);
2360+
/* A Swift URL is usually built rather than written bare, and the
2361+
* literal then sits inside that constructor. */
2362+
if (ctx->language == CBM_LANG_SWIFT) {
2363+
arg = swift_unwrap_url_constructor(ctx, arg);
23122364
}
23132365
const char *ak = ts_node_type(arg);
23142366

‎tests/test_extraction.c‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4186,6 +4186,52 @@ TEST(swift_labeled_call_string_arg_issue1892) {
41864186
PASS();
41874187
}
41884188

4189+
/* Swift has no URL literal, so real code builds one and force-unwraps it. The
4190+
* string then sits two levels below the argument list. */
4191+
TEST(swift_nested_url_constructor_issue1892) {
4192+
CBMFileResult *r = extract("func fetch() { URLSession.shared.dataTask(with: URL(string: "
4193+
"\"https://example.com/api/v1/widgets\")!) }\n",
4194+
CBM_LANG_SWIFT, "t", "Fetch.swift");
4195+
ASSERT_NOT_NULL(r);
4196+
ASSERT_FALSE(r->has_error);
4197+
const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask");
4198+
ASSERT_NOT_NULL(c);
4199+
ASSERT_NOT_NULL(c->first_string_arg);
4200+
ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets");
4201+
cbm_free_result(r);
4202+
PASS();
4203+
}
4204+
4205+
/* Without the trailing "!" the constructor is not wrapped in a
4206+
* postfix_expression, so this covers the other shape. */
4207+
TEST(swift_nested_url_no_bang_issue1892) {
4208+
CBMFileResult *r =
4209+
extract("func fetch() { client.send(to: URLRequest(url: \"/api/v1/widgets/1\")) }\n",
4210+
CBM_LANG_SWIFT, "t", "Send.swift");
4211+
ASSERT_NOT_NULL(r);
4212+
ASSERT_FALSE(r->has_error);
4213+
const CBMCall *c = find_call_by_callee(r, "client.send");
4214+
ASSERT_NOT_NULL(c);
4215+
ASSERT_NOT_NULL(c->first_string_arg);
4216+
ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1");
4217+
cbm_free_result(r);
4218+
PASS();
4219+
}
4220+
4221+
/* A constructor that is not one of the three URL types keeps its own meaning:
4222+
* the outer call must not borrow the inner call's string. */
4223+
TEST(swift_non_url_constructor_untouched_issue1892) {
4224+
CBMFileResult *r = extract("func f() { log.write(to: Formatter(pattern: \"%s-%d\")) }\n",
4225+
CBM_LANG_SWIFT, "t", "Log.swift");
4226+
ASSERT_NOT_NULL(r);
4227+
ASSERT_FALSE(r->has_error);
4228+
const CBMCall *c = find_call_by_callee(r, "log.write");
4229+
ASSERT_NOT_NULL(c);
4230+
ASSERT_NULL(c->first_string_arg);
4231+
cbm_free_result(r);
4232+
PASS();
4233+
}
4234+
41894235
/* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped
41904236
* literal, consumed as client(buildPath(id)). The builder's URL is recorded in
41914237
* the per-file constant map and resolved at the call site, for both return
@@ -7131,6 +7177,9 @@ SUITE(extraction) {
71317177
RUN_TEST(swift_chained_call);
71327178
RUN_TEST(swift_force_unwrap_scanner_shift);
71337179
RUN_TEST(swift_call_string_arg_issue1892);
7180+
RUN_TEST(swift_nested_url_constructor_issue1892);
7181+
RUN_TEST(swift_nested_url_no_bang_issue1892);
7182+
RUN_TEST(swift_non_url_constructor_untouched_issue1892);
71347183
RUN_TEST(swift_labeled_call_string_arg_issue1892);
71357184
RUN_TEST(objc_interface);
71367185
RUN_TEST(objc_implementation);

‎tests/test_pipeline.c‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5690,6 +5690,55 @@ TEST(pipeline_native_fetch_classified_as_http_calls) {
56905690
* no call arguments at all. Alamofire/URLSession were already in the service
56915691
* pattern table; the URL simply never reached it. This is the Swift twin of
56925692
* the TypeScript fetch case above. */
5693+
/* The shape real Swift actually writes: the URL is built by a constructor and
5694+
* force-unwrapped, so the literal is two levels below the argument list. This
5695+
* is what issue #1892 reported from a real project. */
5696+
TEST(pipeline_swift_nested_url_makes_route_issue1892) {
5697+
char tmp[256];
5698+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swiftnested_XXXXXX");
5699+
if (!cbm_mkdtemp(tmp)) {
5700+
FAIL("tmpdir");
5701+
}
5702+
5703+
write_temp_file(tmp, "Sources/Client.swift",
5704+
"import Foundation\n"
5705+
"final class Client {\n"
5706+
" func listWidgets() {\n"
5707+
" URLSession.shared.dataTask(with: "
5708+
"URL(string: \"/api/v1/widgets\")!)\n"
5709+
" }\n"
5710+
"}\n");
5711+
5712+
char db_path[512];
5713+
snprintf(db_path, sizeof(db_path), "%s/swiftnested.db", tmp);
5714+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
5715+
ASSERT_NOT_NULL(p);
5716+
ASSERT_EQ(cbm_pipeline_run(p), 0);
5717+
const char *project = cbm_pipeline_project_name(p);
5718+
5719+
cbm_store_t *s = cbm_store_open_path(db_path);
5720+
ASSERT_NOT_NULL(s);
5721+
5722+
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
5723+
5724+
cbm_node_t *routes = NULL;
5725+
int route_count = 0;
5726+
cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count);
5727+
int widget_routes = 0;
5728+
for (int i = 0; i < route_count; i++) {
5729+
if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) {
5730+
widget_routes++;
5731+
}
5732+
}
5733+
cbm_store_free_nodes(routes, route_count);
5734+
ASSERT_GTE(widget_routes, 1);
5735+
5736+
cbm_store_close(s);
5737+
cbm_pipeline_free(p);
5738+
th_rmtree(tmp);
5739+
PASS();
5740+
}
5741+
56935742
TEST(pipeline_swift_http_call_makes_route_issue1892) {
56945743
char tmp[256];
56955744
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX");
@@ -13475,6 +13524,7 @@ SUITE(pipeline) {
1347513524
RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier);
1347613525
RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments);
1347713526
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
13527+
RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892);
1347813528
RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);
1347913529
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
1348013530
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);

0 commit comments

Comments
 (0)