From d2868cd28c25b5f27b95f8ebb8d7a78b55c677e0 Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 10 Jul 2026 13:25:38 -0300 Subject: [PATCH 1/3] fix: flatten JS/TS template-literal URLs to {} placeholders (#1006) Client-side URL extraction only recognized static string literals; any template literal was silently skipped, so parameterized endpoints never produced HTTP_CALLS edges or Route nodes and cross-repo route matching missed them (the server side already normalizes path params to {}). New cbm_template_string_text() flattens a template_string node: string fragments verbatim, each ${...} substitution becomes {}. Wired into: - extract_positional_url / extract_string_value (call-arg URLs) - handle_string_refs (URL-shaped refs from const/return positions) - handle_string_constants (module-level const lookups) `/api/v1/things/${id}` now yields __route__ANY__/api/v1/things/{} and the enclosing function gets the HTTP_CALLS edge, joining the canonical placeholder shape of server-side routes. --- internal/cbm/extract_calls.c | 11 ++++++++++ internal/cbm/extract_unified.c | 37 +++++++++++++++++++++++++++----- internal/cbm/helpers.c | 39 ++++++++++++++++++++++++++++++++++ internal/cbm/helpers.h | 6 ++++++ tests/test_extraction.c | 32 ++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index afe2938be..4c8d81924 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1404,6 +1404,9 @@ static bool is_queue_topic_field(const char *key) { // Extract string value from a node (literal or constant reference). static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node) { const char *vk = ts_node_type(val_node); + if (strcmp(vk, "template_string") == 0) { + return cbm_template_string_text(ctx->arena, val_node, ctx->source); + } if (is_string_like(vk)) { char *text = cbm_node_text(ctx->arena, val_node, ctx->source); if (text && text[0]) { @@ -1504,6 +1507,14 @@ static const char *extract_keyword_url(CBMExtractCtx *ctx, TSNode arg) { // Try to extract URL/topic from a positional argument (string or constant). 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). */ + if (strcmp(ak, "template_string") == 0) { + const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source); + if (flat) { + return flat; + } + } if (is_string_like(ak)) { char *text = cbm_node_text(ctx->arena, arg, ctx->source); const char *validated = strip_and_validate_string_arg(ctx->arena, text); diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 4b747789a..0365d2194 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -511,20 +511,27 @@ static void handle_string_constants(CBMExtractCtx *ctx, TSNode node, const WalkS return; } - /* Value must be a string literal */ - if (!is_string_node(ts_node_type(value_node))) { + /* Value must be a string literal (template literals flatten to "{}" form) */ + const char *value_kind = ts_node_type(value_node); + const char *flat_value = NULL; + if (strcmp(value_kind, "template_string") == 0) { + flat_value = cbm_template_string_text(ctx->arena, value_node, ctx->source); + if (!flat_value) { + return; + } + } else if (!is_string_node(value_kind)) { return; } char *name = cbm_node_text(ctx->arena, name_node, ctx->source); - char *value = cbm_node_text(ctx->arena, value_node, ctx->source); + char *value = flat_value ? (char *)flat_value : cbm_node_text(ctx->arena, value_node, ctx->source); if (!name || !name[0] || !value || !value[0]) { return; } - /* Strip quotes from value */ + /* Strip quotes from value (template values are already unquoted) */ int vlen = (int)strlen(value); - if (vlen >= CBM_QUOTE_PAIR && (value[0] == '"' || value[0] == '\'')) { + if (!flat_value && vlen >= CBM_QUOTE_PAIR && (value[0] == '"' || value[0] == '\'')) { value = cbm_arena_strndup(ctx->arena, value + SKIP_ONE, (size_t)(vlen - PAIR_LEN)); if (!value) { return; @@ -554,6 +561,26 @@ static bool is_string_node(const char *kind) { static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState *state) { const char *kind = ts_node_type(node); + /* JS/TS template literals: flatten ${...} substitutions to "{}" so URL-ish + * template strings become string_refs with the canonical placeholder shape + * shared with server route paths (issue #1006). */ + if (strcmp(kind, "template_string") == 0) { + const char *flat = cbm_template_string_text(ctx->arena, node, ctx->source); + if (!flat) { + return; + } + int kind_val = cbm_classify_string(flat, (int)strlen(flat)); + if (kind_val < 0) { + return; + } + CBMStringRef ref = { + .value = flat, + .enclosing_func_qn = state->enclosing_func_qn ? state->enclosing_func_qn : ctx->module_qn, + .kind = (CBMStringRefKind)kind_val, + }; + cbm_stringref_push(&ctx->result->string_refs, ctx->arena, ref); + return; + } if (!is_string_node(kind)) { return; } diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 3cea43ab3..5e2e0843c 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1443,3 +1443,42 @@ int cbm_classify_string(const char *str, int len) { return NOT_FOUND; } + +/* Flatten a JS/TS `template_string` node into plain text (issue #1006). + * String fragments are kept verbatim; each ${...} substitution becomes the + * "{}" placeholder so client URLs built from template literals share the + * canonical parameter shape of server-side route paths + * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields + * no text or exceeds the route-sized buffer. */ +const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { + enum { TPL_BUF = 512 }; + char buf[TPL_BUF]; + size_t pos = 0; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(node, i); + const char *k = ts_node_type(c); + if (strcmp(k, "string_fragment") == 0) { + char *frag = cbm_node_text(a, c, source); + if (!frag) { + continue; + } + size_t fl = strlen(frag); + if (pos + fl >= TPL_BUF) { + return NULL; + } + memcpy(buf + pos, frag, fl); + pos += fl; + } else if (strcmp(k, "template_substitution") == 0) { + if (pos + PAIR_LEN >= TPL_BUF) { + return NULL; + } + buf[pos++] = '{'; + buf[pos++] = '}'; + } + } + if (pos == 0) { + return NULL; + } + return cbm_arena_strndup(a, buf, pos); +} diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 6fb136294..6fa147d58 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -149,4 +149,10 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char * // Folder QN: project.dir_parts char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir); +/* Flatten a JS/TS `template_string` node into plain text: string fragments are + * kept verbatim and each ${...} substitution becomes the "{}" placeholder, so + * client-side URLs built from template literals share the canonical parameter + * shape that server-side route paths already use. NULL when empty/oversized. */ +const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source); + #endif // CBM_HELPERS_H diff --git a/tests/test_extraction.c b/tests/test_extraction.c index f8228e46a..622a64f67 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2738,6 +2738,37 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee) return NULL; } +/* Issue #1006: JS/TS template-literal URLs must flatten ${...} substitutions + * to the canonical "{}" placeholder, both as call arguments (HTTP_CALLS) and + * as URL-shaped string_refs collected from const/return positions. */ +TEST(extract_ts_template_string_url_issue1006) { + CBMFileResult *r = extract("export function detailPath(id: string): string {\n" + " return `/api/v1/things/${id}/detail`;\n" + "}\n" + "export function load(id: string) {\n" + " return fetch(`/api/v1/things/${id}`);\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "paths.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "fetch"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/api/v1/things/{}"); + int found = 0; + for (int i = 0; i < r->string_refs.count; i++) { + if (r->string_refs.items[i].value && + strcmp(r->string_refs.items[i].value, "/api/v1/things/{}/detail") == 0) { + found = 1; + break; + } + } + ASSERT(found); + cbm_free_result(r); + PASS(); +} + + /* Reproduce-first: Java module QN must derive from the CONTAINING DIRECTORY, not * the filename stem, so a top-level class `Outer` in `Outer.java` is `t.Outer`, * NOT the doubled `t.Outer.Outer`. The nested method def QN must also equal the @@ -3720,6 +3751,7 @@ SUITE(extraction) { RUN_TEST(js_index_module_qn_not_collide_with_folder); RUN_TEST(python_regular_module_qn_unchanged); RUN_TEST(extract_java_method_annotations_issue382); + RUN_TEST(extract_ts_template_string_url_issue1006); RUN_TEST(extract_java_no_double_class_qn); RUN_TEST(extract_go_no_filename_in_module_qn); RUN_TEST(extract_large_ts_has_functions_issue213); From a350bb7432b774fb1e97b4fc3736b2b3fcd4da09 Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 10 Jul 2026 14:14:53 -0300 Subject: [PATCH 2/3] fix: resolve URL-builder helper calls to Route/HTTP_CALLS (#1009) URL-shaped literals returned from small builder functions never reached call arguments, so client(buildPath(id)) produced no HTTP_CALLS edge and no Route node, static or template alike. handle_url_builders() records builderName -> returned URL in the same per-file constant map used for module-level consts, covering return statements and arrow expression bodies; a call_expression branch in extract_url_or_topic_arg() resolves client(buildPath(id)) through it. Composed builders inline already-recorded substitutions and truncate the query string, so `${basePath(id)}?${params}` joins the server route exactly. Ambiguous builders (two different URLs) are tombstoned. --- internal/cbm/extract_calls.c | 13 +++ internal/cbm/extract_unified.c | 158 +++++++++++++++++++++++++++++++++ tests/test_extraction.c | 59 ++++++++++++ 3 files changed, 230 insertions(+) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 4c8d81924..dcb823775 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1561,6 +1561,19 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { } } + /* URL-builder helper call (issue #1009): `client(buildPath(id))` — the + * builder's returned URL was recorded in the per-file constant map. */ + if (strcmp(ak, "call_expression") == 0) { + TSNode fn = ts_node_child_by_field_name(arg, TS_FIELD("function")); + if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) { + char *fname = cbm_node_text(ctx->arena, fn, ctx->source); + const char *val = fname ? lookup_string_constant(ctx, fname) : NULL; + if (val) { + return val; + } + } + } + if (ai < MAX_POSITIONAL_SCAN) { const char *val = extract_positional_url(ctx, arg, ak); if (val) { diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 0365d2194..0de7ea515 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -622,6 +622,163 @@ static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState cbm_stringref_push(&ctx->result->string_refs, ctx->arena, ref); } +// --- URL-builder helpers (issue #1009) --- + +/* Map-aware template flatten for builder bodies: a ${...} substitution that is + * a bare identifier or a call to an already-recorded name (const or an earlier + * builder in the same file) inlines that value; anything else becomes "{}". + * The query string is not part of a route's identity, so the result is + * truncated at the first '?'. Handles the composed-builder shape + * `return \`${basePath(id)}?${params}\``. */ +static const char *builder_template_text(CBMExtractCtx *ctx, TSNode node) { + enum { BLD_BUF = 512 }; + char buf[BLD_BUF]; + size_t pos = 0; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(node, i); + const char *k = ts_node_type(c); + const char *piece = NULL; + if (strcmp(k, "string_fragment") == 0) { + piece = cbm_node_text(ctx->arena, c, ctx->source); + } else if (strcmp(k, "template_substitution") == 0) { + piece = "{}"; + if (ts_node_named_child_count(c) > 0) { + TSNode expr = ts_node_named_child(c, 0); + const char *ek = ts_node_type(expr); + TSNode name_node = expr; + if (strcmp(ek, "call_expression") == 0) { + name_node = ts_node_child_by_field_name(expr, TS_FIELD("function")); + } + if (!ts_node_is_null(name_node) && + strcmp(ts_node_type(name_node), "identifier") == 0) { + char *nm = cbm_node_text(ctx->arena, name_node, ctx->source); + if (nm) { + const CBMStringConstantMap *map = &ctx->string_constants; + for (int mi = 0; mi < map->count; mi++) { + if (map->values[mi] && strcmp(map->names[mi], nm) == 0) { + piece = map->values[mi]; + break; + } + } + } + } + } + } else { + continue; + } + if (!piece) { + continue; + } + size_t pl = strlen(piece); + if (pos + pl >= BLD_BUF) { + return NULL; + } + memcpy(buf + pos, piece, pl); + pos += pl; + } + /* Route identity excludes the query string. */ + for (size_t qi = 0; qi < pos; qi++) { + if (buf[qi] == '?') { + pos = qi; + break; + } + } + if (pos == 0) { + return NULL; + } + return cbm_arena_strndup(ctx->arena, buf, pos); +} + +/* Flatten a string-ish node (plain string or template literal) to text. */ +static const char *url_builder_literal_text(CBMExtractCtx *ctx, TSNode value_node) { + const char *kind = ts_node_type(value_node); + if (strcmp(kind, "template_string") == 0) { + return builder_template_text(ctx, value_node); + } + if (!is_string_node(kind)) { + return NULL; + } + char *text = cbm_node_text(ctx->arena, value_node, ctx->source); + if (!text || !text[0]) { + return NULL; + } + int len = (int)strlen(text); + if (len >= CBM_QUOTE_PAIR && (text[0] == '"' || text[0] == '\'')) { + text = cbm_arena_strndup(ctx->arena, text + SKIP_ONE, (size_t)(len - PAIR_LEN)); + } + return text; +} + +/* Record `name -> url` in the per-file constant map so call-site resolution + * (lookup_string_constant) can see it. A builder that returns two DIFFERENT + * urls is ambiguous: tombstone it with a NULL value so lookups miss. */ +static void record_url_builder(CBMExtractCtx *ctx, const char *name, const char *url) { + if (!name || !name[0] || !url) { + return; + } + CBMStringConstantMap *map = &ctx->string_constants; + for (int i = 0; i < map->count; i++) { + if (strcmp(map->names[i], name) == 0) { + if (map->values[i] && strcmp(map->values[i], url) != 0) { + map->values[i] = NULL; /* ambiguous builder */ + } + return; + } + } + if (map->count < CBM_MAX_STRING_CONSTANTS) { + map->names[map->count] = (char *)name; + map->values[map->count] = (char *)url; + map->count++; + } +} + +/* URL-builder helper pattern (issue #1009): a small function whose return value + * is a URL-shaped literal, consumed as `client(buildPath(id))`. The literal + * never appears as a call argument, so first_string_arg resolution cannot see + * it; record `functionName -> url` in the same per-file constant map used for + * module-level consts and let the call-site call_expression branch resolve it. + * Covers `return`-statement bodies and arrow-function expression bodies. */ +static void handle_url_builders(CBMExtractCtx *ctx, TSNode node, const WalkState *state) { + const char *kind = ts_node_type(node); + + if (strcmp(kind, "arrow_function") == 0) { + TSNode body = ts_node_child_by_field_name(node, TS_FIELD("body")); + if (ts_node_is_null(body) || strcmp(ts_node_type(body), "statement_block") == 0) { + return; + } + const char *url = url_builder_literal_text(ctx, body); + if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_URL) { + return; + } + TSNode parent = ts_node_parent(node); + if (!ts_node_is_null(parent) && strcmp(ts_node_type(parent), "variable_declarator") == 0) { + TSNode name_node = ts_node_child_by_field_name(parent, TS_FIELD("name")); + if (!ts_node_is_null(name_node)) { + record_url_builder(ctx, cbm_node_text(ctx->arena, name_node, ctx->source), url); + } + } + return; + } + + if (strcmp(kind, "return_statement") != 0) { + return; + } + if (!state->enclosing_func_qn || state->enclosing_func_qn == ctx->module_qn) { + return; + } + if (ts_node_named_child_count(node) == 0) { + return; + } + const char *url = url_builder_literal_text(ctx, ts_node_named_child(node, 0)); + if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_URL) { + return; + } + const char *name = strrchr(state->enclosing_func_qn, '.'); + name = name ? name + 1 : state->enclosing_func_qn; + record_url_builder(ctx, name, url); +} + // --- YAML nested field extraction (D2) --- /* Recursively walk YAML block_mapping_pair nodes, building dotted key paths. @@ -1229,6 +1386,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { recompute_state(&state, ctx->module_qn); handle_string_constants(ctx, node, &state); + handle_url_builders(ctx, node, &state); handle_calls(ctx, node, spec, &state); handle_usages(ctx, node, spec, &state); handle_throws(ctx, node, spec, &state); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 622a64f67..9fc47bb73 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2738,6 +2738,63 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee) return NULL; } +/* 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 + * statements and arrow expression bodies. */ +TEST(extract_ts_url_builder_issue1009) { + CBMFileResult *r = extract("function thingDetail(id: string): string {\n" + " return `/api/v1/things/${id}/detail`;\n" + "}\n" + "const arrowPath = (id: string) => `/api/v1/arrows/${id}`;\n" + "export function useThing(id: string) {\n" + " return apiGet(thingDetail(id));\n" + "}\n" + "export function useArrow(id: string) {\n" + " return apiFetch(arrowPath(id));\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "builders.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c1 = find_call_by_callee(r, "apiGet"); + ASSERT_NOT_NULL(c1); + ASSERT_NOT_NULL(c1->first_string_arg); + ASSERT_STR_EQ(c1->first_string_arg, "/api/v1/things/{}/detail"); + const CBMCall *c2 = find_call_by_callee(r, "apiFetch"); + ASSERT_NOT_NULL(c2); + ASSERT_NOT_NULL(c2->first_string_arg); + ASSERT_STR_EQ(c2->first_string_arg, "/api/v1/arrows/{}"); + cbm_free_result(r); + PASS(); +} + +/* Issue #1009 (composed builders): a builder whose template inlines an earlier + * builder's call plus a query string: `return \`${basePath(id)}?${params}\``. + * The known-substitution is inlined and the query string is truncated, so the + * resolved URL joins the server route exactly. */ +TEST(extract_ts_url_builder_composed_issue1009) { + CBMFileResult *r = extract("function activityPath(id: string): string {\n" + " return `/api/v1/team-members/${id}/activity`;\n" + "}\n" + "function buildPath(id: string, cursor: string): string {\n" + " const params = new URLSearchParams();\n" + " params.set('cursor', cursor);\n" + " return `${activityPath(id)}?${params.toString()}`;\n" + "}\n" + "export function useActivity(id: string, cursor: string) {\n" + " return apiGet(buildPath(id, cursor));\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "composed.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_by_callee(r, "apiGet"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/api/v1/team-members/{}/activity"); + cbm_free_result(r); + PASS(); +} + /* Issue #1006: JS/TS template-literal URLs must flatten ${...} substitutions * to the canonical "{}" placeholder, both as call arguments (HTTP_CALLS) and * as URL-shaped string_refs collected from const/return positions. */ @@ -3752,6 +3809,8 @@ SUITE(extraction) { RUN_TEST(python_regular_module_qn_unchanged); RUN_TEST(extract_java_method_annotations_issue382); RUN_TEST(extract_ts_template_string_url_issue1006); + RUN_TEST(extract_ts_url_builder_issue1009); + RUN_TEST(extract_ts_url_builder_composed_issue1009); RUN_TEST(extract_java_no_double_class_qn); RUN_TEST(extract_go_no_filename_in_module_qn); RUN_TEST(extract_large_ts_has_functions_issue213); From 9d5876901e72926985a74cb40edbd78ce631681f Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 10 Jul 2026 14:28:14 -0300 Subject: [PATCH 3/3] fix: resolve builder calls and template literals in per-arg values too detect_url_in_args() (the arg_url HTTP_CALLS emitter used when the callee is a local client wrapper, not a known HTTP library) reads per-arg values, not first_string_arg. Resolve call_expression args through the builder map and flatten template_string args to the {} form there as well, so wrappers like apiFetch(buildPath(id)) emit the edge and join the canonical route. --- internal/cbm/extract_calls.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index dcb823775..6bc600a97 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1355,8 +1355,22 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { ca->index = positional_idx++; if (is_string_like(ak) && ca->expr) { ca->value = strip_quotes(ctx->arena, ca->expr); + } else if (strcmp(ak, "template_string") == 0) { + /* Flattened {} form so downstream url-arg detection joins the + * canonical server route shape (issue #1006/#1009). */ + ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source); } else if (strcmp(ak, "identifier") == 0 && ca->expr) { ca->value = lookup_string_constant(ctx, ca->expr); + } else if (strcmp(ak, "call_expression") == 0) { + /* URL-builder helper call (issue #1009): resolve + * client(buildPath(id)) through the per-file builder map. */ + TSNode fn = ts_node_child_by_field_name(arg_node, TS_FIELD("function")); + if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) { + char *fname = cbm_node_text(ctx->arena, fn, ctx->source); + if (fname) { + ca->value = lookup_string_constant(ctx, fname); + } + } } call->arg_count++; }