diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index afe2938be..8a8006211 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 strip_and_validate_string_arg(ctx->arena, (char *)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..07c11c1a1 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -511,20 +511,28 @@ 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 +562,27 @@ 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);