Skip to content

Commit acc403a

Browse files
fix(extraction): reach Swift call arguments through call_suffix
handle_calls reads a call's arguments through one tree-sitter field lookup, ts_node_child_by_field_name(node, "arguments"). The vendored Swift grammar declares no "arguments" field at all -- its own ts_field_names[] table has zero occurrences, against one in Go and two in TypeScript. Swift models a call as a target expression plus a call_suffix, and the arguments hang off the suffix as value_arguments. So args was null for every Swift call ever parsed, first_string_arg was never populated, and no Swift HTTP call could raise an HTTP_CALLS edge or a Route node. Alamofire, Moya and URLSession have been in the service-pattern table the whole time and match the callee text correctly; the URL simply never arrived. Three changes, all in extract_calls.c: - swift_call_args() reaches the argument list through call_suffix, in the same shape as the existing objectscript_call_args() fallback and used from the same place. A trailing closure has no value_arguments, so it returns a null node and that call behaves as before. - extract_url_or_topic_arg() unwraps Swift's per-argument value_argument node, stepping past a leading value_argument_label. Without it, dataTask(with: "/api/v1/widgets") yields the label "with" rather than the path. PHP and C# already had the same unwrap for their own "argument" node. - is_string_like() gains line_string_literal, which is what Swift calls an ordinary "..." literal. The list already held raw_string_literal, so only Swift's common case was missing. This is the layer underneath #1892 rather than the whole of it. A literal URL argument now arrives; a literal nested inside a constructor, as in URLSession.shared.data(from: URL(string: "...")!), still does not, because extract_positional_url reads a literal, a template string, a concatenation or a named constant and that shape is none of them. Reproduce-first: all three tests fail without the fix, two on a null first_string_arg and one on HTTP_CALLS being 0. Fixes #1892 Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
1 parent 51c48e2 commit acc403a

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

internal/cbm/extract_calls.c

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ static const char *lookup_url_builder(const CBMExtractCtx *ctx, const char *name
6363
static int is_string_like(const char *kind) {
6464
return (strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 ||
6565
strcmp(kind, "interpreted_string_literal") == 0 ||
66-
strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0);
66+
strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0 ||
67+
strcmp(kind, "line_string_literal") == 0);
6768
}
6869

6970
/* Strip surrounding quotes from a string, return arena-allocated copy */
@@ -2123,6 +2124,18 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
21232124
if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) {
21242125
arg = ts_node_named_child(arg, 0);
21252126
}
2127+
/* Swift wraps each argument in a value_argument that may lead with its
2128+
* 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;
2138+
}
21262139
const char *ak = ts_node_type(arg);
21272140

21282141
if (strcmp(ak, "keyword_argument") == 0 || strcmp(ak, "pair") == 0) {
@@ -2812,6 +2825,19 @@ static TSNode objectscript_call_args(TSNode node) {
28122825
: cbm_find_child_by_kind(macro_function, "method_args");
28132826
}
28142827

2828+
/* Swift models a call as a target expression plus a call_suffix, and its grammar
2829+
* declares no "arguments" field at all, so the generic field lookup finds
2830+
* nothing for every Swift call. Reach the argument list through the suffix
2831+
* instead. A trailing closure has a call_suffix with no value_arguments, which
2832+
* returns a null node and leaves the call without a string argument, as before. */
2833+
static TSNode swift_call_args(TSNode node) {
2834+
TSNode suffix = cbm_find_child_by_kind(node, "call_suffix");
2835+
if (ts_node_is_null(suffix)) {
2836+
return (TSNode){0};
2837+
}
2838+
return cbm_find_child_by_kind(suffix, "value_arguments");
2839+
}
2840+
28152841
static bool node_has_token(TSNode node, const char *token) {
28162842
uint32_t count = ts_node_child_count(node);
28172843
for (uint32_t i = 0; i < count; i++) {
@@ -3387,6 +3413,10 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
33873413
if (ts_node_is_null(args) && is_objectscript_language(ctx->language)) {
33883414
args = objectscript_call_args(node);
33893415
}
3416+
// Swift has no "arguments" field either; its args hang off call_suffix.
3417+
if (ts_node_is_null(args) && ctx->language == CBM_LANG_SWIFT) {
3418+
args = swift_call_args(node);
3419+
}
33903420
if (!ts_node_is_null(args)) {
33913421
call.first_string_arg = extract_url_or_topic_arg(ctx, args);
33923422
/* #952: routes registered inside Laravel `prefix()->group()`

tests/test_extraction.c

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3737,6 +3737,41 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee)
37373737
return NULL;
37383738
}
37393739

3740+
/* #1892: the Swift grammar declares no "arguments" field, so the generic field
3741+
* lookup read nothing and every Swift call lost its arguments. Without the URL
3742+
* the service-pattern table cannot raise an HTTP_CALLS edge or a Route node,
3743+
* even though Alamofire/Moya/URLSession are already listed in it. */
3744+
TEST(swift_call_string_arg_issue1892) {
3745+
CBMFileResult *r =
3746+
extract("func listWidgets() { AF.request(\"https://example.com/api/v1/widgets\") }\n",
3747+
CBM_LANG_SWIFT, "t", "Client.swift");
3748+
ASSERT_NOT_NULL(r);
3749+
ASSERT_FALSE(r->has_error);
3750+
const CBMCall *c = find_call_by_callee(r, "AF.request");
3751+
ASSERT_NOT_NULL(c);
3752+
ASSERT_NOT_NULL(c->first_string_arg);
3753+
ASSERT_STR_EQ(c->first_string_arg, "https://example.com/api/v1/widgets");
3754+
cbm_free_result(r);
3755+
PASS();
3756+
}
3757+
3758+
/* Swift labels its arguments, and each one sits in a value_argument node that
3759+
* leads with the label. Reading the first child alone would return `with`
3760+
* rather than the path. */
3761+
TEST(swift_labeled_call_string_arg_issue1892) {
3762+
CBMFileResult *r =
3763+
extract("func fetch() { URLSession.shared.dataTask(with: \"/api/v1/widgets/1\") }\n",
3764+
CBM_LANG_SWIFT, "t", "Fetch.swift");
3765+
ASSERT_NOT_NULL(r);
3766+
ASSERT_FALSE(r->has_error);
3767+
const CBMCall *c = find_call_by_callee(r, "URLSession.shared.dataTask");
3768+
ASSERT_NOT_NULL(c);
3769+
ASSERT_NOT_NULL(c->first_string_arg);
3770+
ASSERT_STR_EQ(c->first_string_arg, "/api/v1/widgets/1");
3771+
cbm_free_result(r);
3772+
PASS();
3773+
}
3774+
37403775
/* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped
37413776
* literal, consumed as client(buildPath(id)). The builder's URL is recorded in
37423777
* the per-file constant map and resolved at the call site, for both return
@@ -6180,6 +6215,8 @@ SUITE(extraction) {
61806215
RUN_TEST(swift_method_call);
61816216
RUN_TEST(swift_constructor_call);
61826217
RUN_TEST(swift_chained_call);
6218+
RUN_TEST(swift_call_string_arg_issue1892);
6219+
RUN_TEST(swift_labeled_call_string_arg_issue1892);
61836220
RUN_TEST(objc_interface);
61846221
RUN_TEST(objc_implementation);
61856222
RUN_TEST(dart_top_level_function);

tests/test_pipeline.c

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4992,6 +4992,61 @@ TEST(pipeline_native_fetch_classified_as_http_calls) {
49924992
PASS();
49934993
}
49944994

4995+
/* #1892: Swift produced no Route node and no HTTP_CALLS edge, because the
4996+
* Swift grammar has no "arguments" field and the generic lookup therefore read
4997+
* no call arguments at all. Alamofire/URLSession were already in the service
4998+
* pattern table; the URL simply never reached it. This is the Swift twin of
4999+
* the TypeScript fetch case above. */
5000+
TEST(pipeline_swift_http_call_makes_route_issue1892) {
5001+
char tmp[256];
5002+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX");
5003+
if (!cbm_mkdtemp(tmp)) {
5004+
FAIL("tmpdir");
5005+
}
5006+
5007+
/* URLSession, not Alamofire's `AF` shorthand: the service pattern table
5008+
* matches the library name in the callee text, and "AF.request" contains
5009+
* no such name. */
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: \"/api/v1/widgets\")\n"
5015+
" }\n"
5016+
"}\n");
5017+
5018+
char db_path[512];
5019+
snprintf(db_path, sizeof(db_path), "%s/swifthttp.db", tmp);
5020+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
5021+
ASSERT_NOT_NULL(p);
5022+
ASSERT_EQ(cbm_pipeline_run(p), 0);
5023+
const char *project = cbm_pipeline_project_name(p);
5024+
5025+
cbm_store_t *s = cbm_store_open_path(db_path);
5026+
ASSERT_NOT_NULL(s);
5027+
5028+
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
5029+
5030+
/* The edge carries the URL, so pass_route_nodes can mint the Route the
5031+
* cross-repo matcher joins a server route against. */
5032+
cbm_node_t *routes = NULL;
5033+
int route_count = 0;
5034+
cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count);
5035+
int widget_routes = 0;
5036+
for (int i = 0; i < route_count; i++) {
5037+
if (routes[i].qualified_name && strstr(routes[i].qualified_name, "/api/v1/widgets")) {
5038+
widget_routes++;
5039+
}
5040+
}
5041+
cbm_store_free_nodes(routes, route_count);
5042+
ASSERT_GTE(widget_routes, 1);
5043+
5044+
cbm_store_close(s);
5045+
cbm_pipeline_free(p);
5046+
th_rmtree(tmp);
5047+
PASS();
5048+
}
5049+
49955050
/* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's
49965051
* resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls
49975052
* but forces the parallel resolver, since the empty-resolution fallback is a
@@ -12514,6 +12569,7 @@ SUITE(pipeline) {
1251412569
RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);
1251512570
RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier);
1251612571
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
12572+
RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);
1251712573
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
1251812574
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);
1251912575
/* Git history pass */

0 commit comments

Comments
 (0)