Skip to content

Commit 7b528b3

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 DeusData#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 DeusData#1892 Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
1 parent c4e9028 commit 7b528b3

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

22482248
// Try to extract URL/topic from a positional argument (string or constant).
2249+
/* Swift names its call arguments, so each one is a value_argument that may lead
2250+
* with a value_argument_label — the `from:` in `data(from: url)`. Return the
2251+
* value itself, and leave any other node exactly as it came in. */
2252+
static TSNode swift_argument_value(TSNode arg) {
2253+
if (strcmp(ts_node_type(arg), "value_argument") != 0 || ts_node_named_child_count(arg) == 0) {
2254+
return arg;
2255+
}
2256+
TSNode val = ts_node_named_child(arg, 0);
2257+
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2258+
ts_node_named_child_count(arg) > 1) {
2259+
val = ts_node_named_child(arg, 1);
2260+
}
2261+
return val;
2262+
}
2263+
2264+
/* Swift has no URL literal, so almost no real code passes a bare string to a
2265+
* request. It writes `URL(string: "https://…")!` instead, and the literal then
2266+
* sits two levels down: past the trailing `!`, which the grammar models as a
2267+
* postfix_expression, and inside the constructor's own argument list.
2268+
*
2269+
* Unwrap both so that literal is as reachable as a bare one. Only the three
2270+
* Foundation types that take a URL string are unwrapped — any other call keeps
2271+
* its own meaning, and a non-literal argument such as `URL(string: base + path)`
2272+
* falls through to the ordinary handling unchanged. */
2273+
static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) {
2274+
/* Step past a trailing "!" or "?". */
2275+
if (strcmp(ts_node_type(arg), "postfix_expression") == 0) {
2276+
TSNode target = ts_node_child_by_field_name(arg, TS_FIELD("target"));
2277+
if (!ts_node_is_null(target)) {
2278+
arg = target;
2279+
}
2280+
}
2281+
if (strcmp(ts_node_type(arg), "call_expression") != 0) {
2282+
return arg;
2283+
}
2284+
TSNode callee = ts_node_named_child(arg, 0);
2285+
if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "simple_identifier") != 0) {
2286+
return arg;
2287+
}
2288+
const char *name = cbm_node_text(ctx->arena, callee, ctx->source);
2289+
if (!name || (strcmp(name, "URL") != 0 && strcmp(name, "URLComponents") != 0 &&
2290+
strcmp(name, "URLRequest") != 0)) {
2291+
return arg;
2292+
}
2293+
TSNode suffix = cbm_find_child_by_kind(arg, "call_suffix");
2294+
if (ts_node_is_null(suffix)) {
2295+
return arg;
2296+
}
2297+
TSNode inner = cbm_find_child_by_kind(suffix, "value_arguments");
2298+
if (ts_node_is_null(inner) || ts_node_named_child_count(inner) == 0) {
2299+
return arg;
2300+
}
2301+
return swift_argument_value(ts_node_named_child(inner, 0));
2302+
}
2303+
22492304
static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) {
22502305
/* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the
22512306
* 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) {
22892344
}
22902345
/* Swift wraps each argument in a value_argument that may lead with its
22912346
* label, so `data(from: url)` would otherwise yield the label `from`
2292-
* rather than the value. Step past a leading value_argument_label. */
2293-
if (strcmp(ts_node_type(arg), "value_argument") == 0 &&
2294-
ts_node_named_child_count(arg) > 0) {
2295-
TSNode val = ts_node_named_child(arg, 0);
2296-
if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
2297-
ts_node_named_child_count(arg) > 1) {
2298-
val = ts_node_named_child(arg, 1);
2299-
}
2300-
arg = val;
2347+
* rather than the value. */
2348+
arg = swift_argument_value(arg);
2349+
/* A Swift URL is usually built rather than written bare, and the
2350+
* literal then sits inside that constructor. */
2351+
if (ctx->language == CBM_LANG_SWIFT) {
2352+
arg = swift_unwrap_url_constructor(ctx, arg);
23012353
}
23022354
const char *ak = ts_node_type(arg);
23032355

tests/test_extraction.c

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4062,6 +4062,52 @@ TEST(swift_labeled_call_string_arg_issue1892) {
40624062
PASS();
40634063
}
40644064

4065+
/* Swift has no URL literal, so real code builds one and force-unwraps it. The
4066+
* string then sits two levels below the argument list. */
4067+
TEST(swift_nested_url_constructor_issue1892) {
4068+
CBMFileResult *r = extract("func fetch() { URLSession.shared.dataTask(with: URL(string: "
4069+
"\"https://example.com/api/v1/widgets\")!) }\n",
4070+
CBM_LANG_SWIFT, "t", "Fetch.swift");
4071+
ASSERT_NOT_NULL(r);
4072+
ASSERT_FALSE(r->has_error);
4073+
const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask");
4074+
ASSERT_NOT_NULL(c);
4075+
ASSERT_NOT_NULL(c->first_string_arg);
4076+
ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets");
4077+
cbm_free_result(r);
4078+
PASS();
4079+
}
4080+
4081+
/* Without the trailing "!" the constructor is not wrapped in a
4082+
* postfix_expression, so this covers the other shape. */
4083+
TEST(swift_nested_url_no_bang_issue1892) {
4084+
CBMFileResult *r =
4085+
extract("func fetch() { client.send(to: URLRequest(url: \"/api/v1/widgets/1\")) }\n",
4086+
CBM_LANG_SWIFT, "t", "Send.swift");
4087+
ASSERT_NOT_NULL(r);
4088+
ASSERT_FALSE(r->has_error);
4089+
const CBMCall *c = find_call_by_callee(r, "client.send");
4090+
ASSERT_NOT_NULL(c);
4091+
ASSERT_NOT_NULL(c->first_string_arg);
4092+
ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1");
4093+
cbm_free_result(r);
4094+
PASS();
4095+
}
4096+
4097+
/* A constructor that is not one of the three URL types keeps its own meaning:
4098+
* the outer call must not borrow the inner call's string. */
4099+
TEST(swift_non_url_constructor_untouched_issue1892) {
4100+
CBMFileResult *r = extract("func f() { log.write(to: Formatter(pattern: \"%s-%d\")) }\n",
4101+
CBM_LANG_SWIFT, "t", "Log.swift");
4102+
ASSERT_NOT_NULL(r);
4103+
ASSERT_FALSE(r->has_error);
4104+
const CBMCall *c = find_call_by_callee(r, "log.write");
4105+
ASSERT_NOT_NULL(c);
4106+
ASSERT_NULL(c->first_string_arg);
4107+
cbm_free_result(r);
4108+
PASS();
4109+
}
4110+
40654111
/* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped
40664112
* literal, consumed as client(buildPath(id)). The builder's URL is recorded in
40674113
* the per-file constant map and resolved at the call site, for both return
@@ -6818,6 +6864,9 @@ SUITE(extraction) {
68186864
RUN_TEST(swift_chained_call);
68196865
RUN_TEST(swift_force_unwrap_scanner_shift);
68206866
RUN_TEST(swift_call_string_arg_issue1892);
6867+
RUN_TEST(swift_nested_url_constructor_issue1892);
6868+
RUN_TEST(swift_nested_url_no_bang_issue1892);
6869+
RUN_TEST(swift_non_url_constructor_untouched_issue1892);
68216870
RUN_TEST(swift_labeled_call_string_arg_issue1892);
68226871
RUN_TEST(objc_interface);
68236872
RUN_TEST(objc_implementation);

tests/test_pipeline.c

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5485,6 +5485,55 @@ TEST(pipeline_native_fetch_classified_as_http_calls) {
54855485
* no call arguments at all. Alamofire/URLSession were already in the service
54865486
* pattern table; the URL simply never reached it. This is the Swift twin of
54875487
* the TypeScript fetch case above. */
5488+
/* The shape real Swift actually writes: the URL is built by a constructor and
5489+
* force-unwrapped, so the literal is two levels below the argument list. This
5490+
* is what issue #1892 reported from a real project. */
5491+
TEST(pipeline_swift_nested_url_makes_route_issue1892) {
5492+
char tmp[256];
5493+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swiftnested_XXXXXX");
5494+
if (!cbm_mkdtemp(tmp)) {
5495+
FAIL("tmpdir");
5496+
}
5497+
5498+
write_temp_file(tmp, "Sources/Client.swift",
5499+
"import Foundation\n"
5500+
"final class Client {\n"
5501+
" func listWidgets() {\n"
5502+
" URLSession.shared.dataTask(with: "
5503+
"URL(string: \"/api/v1/widgets\")!)\n"
5504+
" }\n"
5505+
"}\n");
5506+
5507+
char db_path[512];
5508+
snprintf(db_path, sizeof(db_path), "%s/swiftnested.db", tmp);
5509+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
5510+
ASSERT_NOT_NULL(p);
5511+
ASSERT_EQ(cbm_pipeline_run(p), 0);
5512+
const char *project = cbm_pipeline_project_name(p);
5513+
5514+
cbm_store_t *s = cbm_store_open_path(db_path);
5515+
ASSERT_NOT_NULL(s);
5516+
5517+
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
5518+
5519+
cbm_node_t *routes = NULL;
5520+
int route_count = 0;
5521+
cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count);
5522+
int widget_routes = 0;
5523+
for (int i = 0; i < route_count; i++) {
5524+
if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) {
5525+
widget_routes++;
5526+
}
5527+
}
5528+
cbm_store_free_nodes(routes, route_count);
5529+
ASSERT_GTE(widget_routes, 1);
5530+
5531+
cbm_store_close(s);
5532+
cbm_pipeline_free(p);
5533+
th_rmtree(tmp);
5534+
PASS();
5535+
}
5536+
54885537
TEST(pipeline_swift_http_call_makes_route_issue1892) {
54895538
char tmp[256];
54905539
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX");
@@ -13200,6 +13249,7 @@ SUITE(pipeline) {
1320013249
RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier);
1320113250
RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments);
1320213251
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
13252+
RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892);
1320313253
RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);
1320413254
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
1320513255
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);

0 commit comments

Comments
 (0)