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>
This commit is contained in:
Joshua Richter
2026-09-06 00:43:50 -04:00
parent 7b0f553cba
commit eb80a4ecff
3 changed files with 160 additions and 9 deletions
+61 -9
View File
@@ -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). */
@@ -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);
+49
View File
@@ -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
@@ -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);
+50
View File
@@ -5690,6 +5690,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");
@@ -13475,6 +13524,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);