mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
app/**/route.{ts,js} files produced 0 Route nodes, so cross-repo
intelligence found no edges into a Next.js backend (#759).
cbm_extract_nextjs_routes fills route_path/route_method on the
existing Function definition for each exported HTTP-verb handler.
It adds no pass and no synthetic definitions; the route-node pass
builds the Route and HANDLES edge as it does for other frameworks.
CBM_SEMANTIC_INDEX_VERSION goes from 9 to 10 so existing indexes
re-extract once.
Fixes #759
Signed-off-by: Petteri Ponsimaa <petterip@gmail.com>
273 lines
10 KiB
C
273 lines
10 KiB
C
/*
|
|
* extract_nextjs.c -- Next.js App Router route handlers (#759): an HTTP verb a
|
|
* route file (`app/.../route.ts` or `route.js`, the app directory at the root
|
|
* or under src/) exports serves the path its directories name.
|
|
*
|
|
* The handler is the Function definition extraction already made for the
|
|
* export: this fills its route_path / route_method, so its Route node and
|
|
* HANDLES edge come from the route pass exactly as a decorator route's do, on
|
|
* a full and on an incremental index alike. The path is spelled as SvelteKit's
|
|
* (sveltekit_route_path, pass_route_nodes.c): `[id]` is `:id`, `[...slug]` is
|
|
* `*slug` and a `(group)` directory adds no segment.
|
|
*
|
|
* A directory this cannot spell makes the file no route at all: an optional
|
|
* catch-all (`[[...slug]]`), a private folder (`_lib`), a parallel slot
|
|
* (`@modal`), an intercepting route (`(.)photo`), a malformed bracket, or a
|
|
* path that does not fit route_path. A handler is a Function of this file:
|
|
* `export [async] function GET`, `export const GET = [async] (...) => ...`,
|
|
* or `export { GET }` of such a declaration. A renamed
|
|
* (`export { handler as GET }`), re-exported, destructured or wrapped
|
|
* (`export const GET = auth(handler)`) handler, or a `function` expression
|
|
* bound to a const, has no Function of its own here and is not routed. Each
|
|
* file is judged alone: a src/app/ route file is routed even where a root
|
|
* app/ directory makes Next.js ignore src/app/.
|
|
*/
|
|
#include "cbm.h"
|
|
#include "foundation/arena.h"
|
|
#include "helpers.h"
|
|
#include "tree_sitter/api.h"
|
|
#include "foundation/constants.h"
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
|
|
enum {
|
|
NX_PATH_MAX = CBM_SZ_256, /* the route pass rebuilds a Route from this much */
|
|
NX_REST_LEN = 3, /* "..." */
|
|
};
|
|
|
|
/* The path of a route file below its app directory, NULL for any other file. */
|
|
static const char *nx_app_relative(const char *rel_path) {
|
|
const char *base = rel_path ? strrchr(rel_path, '/') : NULL;
|
|
if (!base || (strcmp(base, "/route.ts") != 0 && strcmp(base, "/route.js") != 0)) {
|
|
return NULL;
|
|
}
|
|
if (strncmp(rel_path, "app/", strlen("app/")) == 0) {
|
|
return rel_path + strlen("app/");
|
|
}
|
|
if (strncmp(rel_path, "src/app/", strlen("src/app/")) == 0) {
|
|
return rel_path + strlen("src/app/");
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static bool nx_has_any(const char *s, size_t n, const char *chars) {
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (strchr(chars, s[i])) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Append directory segment seg (n bytes) to the route path out[*len]. False
|
|
* when the segment cannot be spelled or the path would not fit. */
|
|
static bool nx_append_segment(char *out, size_t *len, const char *seg, size_t n) {
|
|
if (n == 0 || seg[0] == '_' || seg[0] == '@') {
|
|
return false;
|
|
}
|
|
if (seg[0] == '(') {
|
|
/* A route group adds nothing; `(.)x`, `(..)x` and `(...)x` intercept. */
|
|
return n > PAIR_LEN && seg[n - SKIP_ONE] == ')' && seg[SKIP_ONE] != '.' &&
|
|
!nx_has_any(seg + SKIP_ONE, n - PAIR_LEN, "()");
|
|
}
|
|
char marker = '\0';
|
|
if (seg[0] == '[') {
|
|
if (seg[n - SKIP_ONE] != ']') {
|
|
return false;
|
|
}
|
|
marker = ':';
|
|
seg += SKIP_ONE;
|
|
n -= PAIR_LEN;
|
|
if (n >= NX_REST_LEN && strncmp(seg, "...", NX_REST_LEN) == 0) {
|
|
marker = '*';
|
|
seg += NX_REST_LEN;
|
|
n -= NX_REST_LEN;
|
|
}
|
|
if (n == 0 || nx_has_any(seg, n, "[].")) {
|
|
return false;
|
|
}
|
|
} else if (nx_has_any(seg, n, "[]")) {
|
|
return false;
|
|
}
|
|
size_t need = SKIP_ONE + (marker ? SKIP_ONE : 0) + n;
|
|
if (*len + need >= NX_PATH_MAX) {
|
|
return false;
|
|
}
|
|
out[(*len)++] = '/';
|
|
if (marker) {
|
|
out[(*len)++] = marker;
|
|
}
|
|
memcpy(out + *len, seg, n);
|
|
*len += n;
|
|
out[*len] = '\0';
|
|
return true;
|
|
}
|
|
|
|
/* The route path of the file being extracted, in the arena; NULL for none. */
|
|
static const char *nx_route_path(CBMExtractCtx *ctx) {
|
|
const char *seg = nx_app_relative(ctx->rel_path);
|
|
if (!seg) {
|
|
return NULL;
|
|
}
|
|
const char *base = strrchr(ctx->rel_path, '/');
|
|
char path[NX_PATH_MAX] = "/";
|
|
size_t len = 0;
|
|
while (seg < base) {
|
|
const char *end = strchr(seg, '/');
|
|
if (!nx_append_segment(path, &len, seg, (size_t)(end - seg))) {
|
|
return NULL;
|
|
}
|
|
seg = end + SKIP_ONE;
|
|
}
|
|
return cbm_arena_strdup(ctx->arena, path);
|
|
}
|
|
|
|
static bool nx_is_method(const char *name) {
|
|
static const char *const methods[] = {"GET", "POST", "PUT", "PATCH",
|
|
"DELETE", "HEAD", "OPTIONS"};
|
|
for (size_t i = 0; i < sizeof(methods) / sizeof(methods[0]); i++) {
|
|
if (strcmp(name, methods[i]) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool nx_is(TSNode n, const char *type) {
|
|
return !ts_node_is_null(n) && strcmp(ts_node_type(n), type) == 0;
|
|
}
|
|
|
|
/* Does a node carry the anonymous keyword child kw (`default`, `type`)? */
|
|
static bool nx_has_keyword(TSNode n, const char *kw) {
|
|
uint32_t count = ts_node_child_count(n);
|
|
for (uint32_t i = 0; i < count; i++) {
|
|
TSNode c = ts_node_child(n, i);
|
|
if (!ts_node_is_named(c) && strcmp(ts_node_type(c), kw) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* The name of a declarator whose value is an arrow function, the Function
|
|
* extraction names after the binding (`GET = () => ...`); a null node for any
|
|
* other declarator. */
|
|
static TSNode nx_arrow_binding(TSNode declarator) {
|
|
TSNode value = ts_node_child_by_field_name(declarator, TS_FIELD("value"));
|
|
TSNode name = ts_node_child_by_field_name(declarator, TS_FIELD("name"));
|
|
if (!nx_is(name, "identifier") || !nx_is(value, "arrow_function")) {
|
|
return (TSNode){0};
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/* Route the Function definition named by name_node, declared inside decl. */
|
|
static void nx_route_handler(CBMExtractCtx *ctx, TSNode decl, TSNode name_node, const char *path) {
|
|
const char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
|
|
if (!name || !nx_is_method(name)) {
|
|
return;
|
|
}
|
|
const char *qn =
|
|
cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language);
|
|
uint32_t first = ts_node_start_point(decl).row + TS_LINE_OFFSET;
|
|
uint32_t last = ts_node_end_point(decl).row + TS_LINE_OFFSET;
|
|
for (int i = 0; qn && i < ctx->result->defs.count; i++) {
|
|
CBMDefinition *def = &ctx->result->defs.items[i];
|
|
if (!def->route_path && def->label && def->qualified_name &&
|
|
strcmp(def->label, "Function") == 0 && strcmp(def->qualified_name, qn) == 0 &&
|
|
def->start_line >= first && def->end_line <= last) {
|
|
def->route_path = path;
|
|
def->route_method = name;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Does the name node, if any, spell want? Any name does when want is NULL. */
|
|
static bool nx_wanted(CBMExtractCtx *ctx, TSNode name, const char *want) {
|
|
if (ts_node_is_null(name)) {
|
|
return false;
|
|
}
|
|
const char *text = want ? cbm_node_text(ctx->arena, name, ctx->source) : NULL;
|
|
return !want || (text && strcmp(text, want) == 0);
|
|
}
|
|
|
|
/* Route the handlers a top-level declaration (function or const) defines.
|
|
* Only names equal to want are considered when want is set. */
|
|
static void nx_route_declaration(CBMExtractCtx *ctx, TSNode decl, const char *path,
|
|
const char *want) {
|
|
if (nx_is(decl, "function_declaration")) {
|
|
TSNode name = ts_node_child_by_field_name(decl, TS_FIELD("name"));
|
|
if (nx_wanted(ctx, name, want)) {
|
|
nx_route_handler(ctx, decl, name, path);
|
|
}
|
|
return;
|
|
}
|
|
if (!nx_is(decl, "lexical_declaration") && !nx_is(decl, "variable_declaration")) {
|
|
return;
|
|
}
|
|
uint32_t count = ts_node_named_child_count(decl);
|
|
for (uint32_t i = 0; i < count; i++) {
|
|
TSNode declarator = ts_node_named_child(decl, i);
|
|
TSNode name =
|
|
nx_is(declarator, "variable_declarator") ? nx_arrow_binding(declarator) : (TSNode){0};
|
|
if (nx_wanted(ctx, name, want)) {
|
|
nx_route_handler(ctx, declarator, name, path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* `export { GET }`: route the file's own top-level declaration of each
|
|
* unrenamed, non-type specifier. */
|
|
static void nx_route_export_clause(CBMExtractCtx *ctx, TSNode clause, const char *path) {
|
|
uint32_t count = ts_node_named_child_count(clause);
|
|
uint32_t top = ts_node_named_child_count(ctx->root);
|
|
for (uint32_t i = 0; i < count; i++) {
|
|
TSNode spec = ts_node_named_child(clause, i);
|
|
TSNode name = ts_node_child_by_field_name(spec, TS_FIELD("name"));
|
|
if (!nx_is(spec, "export_specifier") || !nx_is(name, "identifier") ||
|
|
!ts_node_is_null(ts_node_child_by_field_name(spec, TS_FIELD("alias"))) ||
|
|
nx_has_keyword(spec, "type") || nx_has_keyword(spec, "typeof")) {
|
|
continue;
|
|
}
|
|
const char *want = cbm_node_text(ctx->arena, name, ctx->source);
|
|
for (uint32_t j = 0; want && j < top; j++) {
|
|
nx_route_declaration(ctx, ts_node_named_child(ctx->root, j), path, want);
|
|
}
|
|
}
|
|
}
|
|
|
|
void cbm_extract_nextjs_routes(CBMExtractCtx *ctx) {
|
|
if (ctx->language != CBM_LANG_TYPESCRIPT && ctx->language != CBM_LANG_JAVASCRIPT) {
|
|
return;
|
|
}
|
|
const char *path = nx_route_path(ctx);
|
|
if (!path) {
|
|
return;
|
|
}
|
|
uint32_t count = ts_node_named_child_count(ctx->root);
|
|
for (uint32_t i = 0; i < count; i++) {
|
|
TSNode node = ts_node_named_child(ctx->root, i);
|
|
/* A runtime export of this file: not `export default`, not
|
|
* `export type { ... }`, not a re-export `... from './x'`. */
|
|
if (!nx_is(node, "export_statement") || nx_has_keyword(node, "default") ||
|
|
nx_has_keyword(node, "type") ||
|
|
!ts_node_is_null(ts_node_child_by_field_name(node, TS_FIELD("source")))) {
|
|
continue;
|
|
}
|
|
TSNode decl = ts_node_child_by_field_name(node, TS_FIELD("declaration"));
|
|
if (!ts_node_is_null(decl)) {
|
|
nx_route_declaration(ctx, decl, path, NULL);
|
|
continue;
|
|
}
|
|
uint32_t parts = ts_node_named_child_count(node);
|
|
for (uint32_t j = 0; j < parts; j++) {
|
|
TSNode part = ts_node_named_child(node, j);
|
|
if (nx_is(part, "export_clause")) {
|
|
nx_route_export_clause(ctx, part, path);
|
|
}
|
|
}
|
|
}
|
|
}
|