Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 34 additions & 5 deletions internal/cbm/extract_unified.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
39 changes: 39 additions & 0 deletions internal/cbm/helpers.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
6 changes: 6 additions & 0 deletions internal/cbm/helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions tests/test_extraction.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading