Skip to content

Commit 007ee18

Browse files
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. Signed-off-by: Charles Queiroz <fcqueiroz@liquibase.com>
1 parent 6b57db2 commit 007ee18

5 files changed

Lines changed: 122 additions & 5 deletions

File tree

internal/cbm/extract_calls.c

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,6 +1404,9 @@ static bool is_queue_topic_field(const char *key) {
14041404
// Extract string value from a node (literal or constant reference).
14051405
static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node) {
14061406
const char *vk = ts_node_type(val_node);
1407+
if (strcmp(vk, "template_string") == 0) {
1408+
return cbm_template_string_text(ctx->arena, val_node, ctx->source);
1409+
}
14071410
if (is_string_like(vk)) {
14081411
char *text = cbm_node_text(ctx->arena, val_node, ctx->source);
14091412
if (text && text[0]) {
@@ -1504,6 +1507,14 @@ static const char *extract_keyword_url(CBMExtractCtx *ctx, TSNode arg) {
15041507

15051508
// Try to extract URL/topic from a positional argument (string or constant).
15061509
static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) {
1510+
/* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the
1511+
* client URL joins the server route's canonical placeholder (issue #1006). */
1512+
if (strcmp(ak, "template_string") == 0) {
1513+
const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source);
1514+
if (flat) {
1515+
return strip_and_validate_string_arg(ctx->arena, (char *)flat);
1516+
}
1517+
}
15071518
if (is_string_like(ak)) {
15081519
char *text = cbm_node_text(ctx->arena, arg, ctx->source);
15091520
const char *validated = strip_and_validate_string_arg(ctx->arena, text);

internal/cbm/extract_unified.c

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -511,20 +511,28 @@ static void handle_string_constants(CBMExtractCtx *ctx, TSNode node, const WalkS
511511
return;
512512
}
513513

514-
/* Value must be a string literal */
515-
if (!is_string_node(ts_node_type(value_node))) {
514+
/* Value must be a string literal (template literals flatten to "{}" form) */
515+
const char *value_kind = ts_node_type(value_node);
516+
const char *flat_value = NULL;
517+
if (strcmp(value_kind, "template_string") == 0) {
518+
flat_value = cbm_template_string_text(ctx->arena, value_node, ctx->source);
519+
if (!flat_value) {
520+
return;
521+
}
522+
} else if (!is_string_node(value_kind)) {
516523
return;
517524
}
518525

519526
char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
520-
char *value = cbm_node_text(ctx->arena, value_node, ctx->source);
527+
char *value =
528+
flat_value ? (char *)flat_value : cbm_node_text(ctx->arena, value_node, ctx->source);
521529
if (!name || !name[0] || !value || !value[0]) {
522530
return;
523531
}
524532

525-
/* Strip quotes from value */
533+
/* Strip quotes from value (template values are already unquoted) */
526534
int vlen = (int)strlen(value);
527-
if (vlen >= CBM_QUOTE_PAIR && (value[0] == '"' || value[0] == '\'')) {
535+
if (!flat_value && vlen >= CBM_QUOTE_PAIR && (value[0] == '"' || value[0] == '\'')) {
528536
value = cbm_arena_strndup(ctx->arena, value + SKIP_ONE, (size_t)(vlen - PAIR_LEN));
529537
if (!value) {
530538
return;
@@ -554,6 +562,27 @@ static bool is_string_node(const char *kind) {
554562

555563
static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState *state) {
556564
const char *kind = ts_node_type(node);
565+
/* JS/TS template literals: flatten ${...} substitutions to "{}" so URL-ish
566+
* template strings become string_refs with the canonical placeholder shape
567+
* shared with server route paths (issue #1006). */
568+
if (strcmp(kind, "template_string") == 0) {
569+
const char *flat = cbm_template_string_text(ctx->arena, node, ctx->source);
570+
if (!flat) {
571+
return;
572+
}
573+
int kind_val = cbm_classify_string(flat, (int)strlen(flat));
574+
if (kind_val < 0) {
575+
return;
576+
}
577+
CBMStringRef ref = {
578+
.value = flat,
579+
.enclosing_func_qn =
580+
state->enclosing_func_qn ? state->enclosing_func_qn : ctx->module_qn,
581+
.kind = (CBMStringRefKind)kind_val,
582+
};
583+
cbm_stringref_push(&ctx->result->string_refs, ctx->arena, ref);
584+
return;
585+
}
557586
if (!is_string_node(kind)) {
558587
return;
559588
}

internal/cbm/helpers.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,3 +1443,42 @@ int cbm_classify_string(const char *str, int len) {
14431443

14441444
return NOT_FOUND;
14451445
}
1446+
1447+
/* Flatten a JS/TS `template_string` node into plain text (issue #1006).
1448+
* String fragments are kept verbatim; each ${...} substitution becomes the
1449+
* "{}" placeholder so client URLs built from template literals share the
1450+
* canonical parameter shape of server-side route paths
1451+
* (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields
1452+
* no text or exceeds the route-sized buffer. */
1453+
const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) {
1454+
enum { TPL_BUF = 512 };
1455+
char buf[TPL_BUF];
1456+
size_t pos = 0;
1457+
uint32_t nc = ts_node_named_child_count(node);
1458+
for (uint32_t i = 0; i < nc; i++) {
1459+
TSNode c = ts_node_named_child(node, i);
1460+
const char *k = ts_node_type(c);
1461+
if (strcmp(k, "string_fragment") == 0) {
1462+
char *frag = cbm_node_text(a, c, source);
1463+
if (!frag) {
1464+
continue;
1465+
}
1466+
size_t fl = strlen(frag);
1467+
if (pos + fl >= TPL_BUF) {
1468+
return NULL;
1469+
}
1470+
memcpy(buf + pos, frag, fl);
1471+
pos += fl;
1472+
} else if (strcmp(k, "template_substitution") == 0) {
1473+
if (pos + PAIR_LEN >= TPL_BUF) {
1474+
return NULL;
1475+
}
1476+
buf[pos++] = '{';
1477+
buf[pos++] = '}';
1478+
}
1479+
}
1480+
if (pos == 0) {
1481+
return NULL;
1482+
}
1483+
return cbm_arena_strndup(a, buf, pos);
1484+
}

internal/cbm/helpers.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,10 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char *
149149
// Folder QN: project.dir_parts
150150
char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir);
151151

152+
/* Flatten a JS/TS `template_string` node into plain text: string fragments are
153+
* kept verbatim and each ${...} substitution becomes the "{}" placeholder, so
154+
* client-side URLs built from template literals share the canonical parameter
155+
* shape that server-side route paths already use. NULL when empty/oversized. */
156+
const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source);
157+
152158
#endif // CBM_HELPERS_H

tests/test_extraction.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2738,6 +2738,37 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee)
27382738
return NULL;
27392739
}
27402740

2741+
/* Issue #1006: JS/TS template-literal URLs must flatten ${...} substitutions
2742+
* to the canonical "{}" placeholder, both as call arguments (HTTP_CALLS) and
2743+
* as URL-shaped string_refs collected from const/return positions. */
2744+
TEST(extract_ts_template_string_url_issue1006) {
2745+
CBMFileResult *r = extract("export function detailPath(id: string): string {\n"
2746+
" return `/api/v1/things/${id}/detail`;\n"
2747+
"}\n"
2748+
"export function load(id: string) {\n"
2749+
" return fetch(`/api/v1/things/${id}`);\n"
2750+
"}\n",
2751+
CBM_LANG_TYPESCRIPT, "t", "paths.ts");
2752+
ASSERT_NOT_NULL(r);
2753+
ASSERT_FALSE(r->has_error);
2754+
const CBMCall *c = find_call_by_callee(r, "fetch");
2755+
ASSERT_NOT_NULL(c);
2756+
ASSERT_NOT_NULL(c->first_string_arg);
2757+
ASSERT_STR_EQ(c->first_string_arg, "/api/v1/things/{}");
2758+
int found = 0;
2759+
for (int i = 0; i < r->string_refs.count; i++) {
2760+
if (r->string_refs.items[i].value &&
2761+
strcmp(r->string_refs.items[i].value, "/api/v1/things/{}/detail") == 0) {
2762+
found = 1;
2763+
break;
2764+
}
2765+
}
2766+
ASSERT(found);
2767+
cbm_free_result(r);
2768+
PASS();
2769+
}
2770+
2771+
27412772
/* Reproduce-first: Java module QN must derive from the CONTAINING DIRECTORY, not
27422773
* the filename stem, so a top-level class `Outer` in `Outer.java` is `t.Outer`,
27432774
* NOT the doubled `t.Outer.Outer`. The nested method def QN must also equal the
@@ -3720,6 +3751,7 @@ SUITE(extraction) {
37203751
RUN_TEST(js_index_module_qn_not_collide_with_folder);
37213752
RUN_TEST(python_regular_module_qn_unchanged);
37223753
RUN_TEST(extract_java_method_annotations_issue382);
3754+
RUN_TEST(extract_ts_template_string_url_issue1006);
37233755
RUN_TEST(extract_java_no_double_class_qn);
37243756
RUN_TEST(extract_go_no_filename_in_module_qn);
37253757
RUN_TEST(extract_large_ts_has_functions_issue213);

0 commit comments

Comments
 (0)