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
38 changes: 38 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
Expand Down Expand Up @@ -1404,6 +1418,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 +1521,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);
Expand Down Expand Up @@ -1550,6 +1575,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) {
Expand Down
195 changes: 190 additions & 5 deletions internal/cbm/extract_unified.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -595,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.
Expand Down Expand Up @@ -1202,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);
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
Loading
Loading