diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 6cd935c50..0b6e13da9 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3584,6 +3584,325 @@ static const char *join_route_paths(CBMArena *a, const char *prefix, const char return cbm_arena_sprintf(a, "%s%s", prefix, path); } +/* ── ASP.NET Core attribute routing (C#, #1041 / #1692) ────────────────────── + * Controllers route through attributes rather than Spring-style mapping names: + * [Route("api/[controller]")] class QuotesController -> class prefix + * [HttpGet("{symbol}")] / [HttpPost] + [Route("x/{id}")] -> action route + * [AcceptVerbs("GET", "HEAD", Route = "x")] + * A template is relative to the class prefix unless it starts with "/" or + * "~/". The [controller]/[action]/[area] tokens are replaced and constraints, + * defaults and optional markers reduce to the bare parameter ("{id:int}" -> + * "{id}"), so the result leads with '/' and canonicalizes like any other + * decorator route. */ +typedef struct { + const char *method; /* HTTP verb of the first verb attribute */ + const char *verb_tmpl; /* its template (AcceptVerbs: Route = "...") */ + const char *route_tmpl; /* template of the first [Route] */ + const char *action_name; /* [ActionName("x")] */ + const char *area; /* [Area("x")] */ + bool has_route; /* a [Route] attribute was seen */ +} aspnet_attrs_t; + +/* Attribute name without namespace qualifier or "Attribute" suffix: + * Microsoft.AspNetCore.Mvc.HttpGetAttribute -> HttpGet. */ +static const char *aspnet_attr_name(CBMArena *a, TSNode attr, const char *source) { + TSNode name_node = ts_node_child_by_field_name(attr, TS_FIELD("name")); + if (ts_node_is_null(name_node)) { + return NULL; + } + const char *name = cbm_node_text(a, name_node, source); + if (!name) { + return NULL; + } + const char *sep = strrchr(name, '.'); + const char *colon = strrchr(name, ':'); + if (colon > sep) { + sep = colon; + } + if (sep) { + name = sep + SKIP_CHAR; + } + static const char suffix[] = "Attribute"; + size_t len = strlen(name); + size_t slen = sizeof(suffix) - SKIP_CHAR; + if (len > slen && strcmp(name + len - slen, suffix) == 0) { + return cbm_arena_strndup(a, name, len - slen); + } + return name; +} + +static const char *aspnet_verb(const char *name) { + static const char *const verbs[][PAIR_CHARS] = { + {"HttpGet", "GET"}, {"HttpPost", "POST"}, {"HttpPut", "PUT"}, + {"HttpDelete", "DELETE"}, {"HttpPatch", "PATCH"}, {"HttpHead", "HEAD"}, + {"HttpOptions", "OPTIONS"}}; + for (size_t i = 0; i < sizeof(verbs) / sizeof(verbs[0]); i++) { + if (strcmp(name, verbs[i][0]) == 0) { + return verbs[i][SKIP_CHAR]; + } + } + return NULL; +} + +/* Unquoted text of a C# "x" or @"x" string literal node; NULL otherwise. */ +static const char *aspnet_string_value(CBMArena *a, TSNode node, const char *source) { + const char *kind = ts_node_type(node); + bool verbatim = strcmp(kind, "verbatim_string_literal") == 0; + if (!verbatim && strcmp(kind, "string_literal") != 0) { + return NULL; + } + const char *text = cbm_node_text(a, node, source); + size_t open = verbatim ? PAIR_CHARS : SKIP_CHAR; /* `@"` or `"` */ + size_t len = text ? strlen(text) : 0; + if (len < open + SKIP_CHAR) { + return NULL; + } + return cbm_arena_strndup(a, text + open, len - open - SKIP_CHAR); +} + +/* The first positional string argument (key == NULL) or the string value of + * the named argument `key` (Route = "..."). */ +static const char *aspnet_attr_arg(CBMArena *a, TSNode attr, const char *source, const char *key) { + TSNode args = cbm_find_child_by_kind(attr, "attribute_argument_list"); + if (ts_node_is_null(args)) { + return NULL; + } + uint32_t nc = ts_node_named_child_count(args); + for (uint32_t i = 0; i < nc; i++) { + TSNode arg = ts_node_named_child(args, i); + uint32_t vc = ts_node_named_child_count(arg); + if (strcmp(ts_node_type(arg), "attribute_argument") != 0 || vc == 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(arg, TS_FIELD("name")); + TSNode value = ts_node_named_child(arg, vc - SKIP_CHAR); + if (ts_node_is_null(name) != (key == NULL)) { + continue; /* positional wanted but named seen, or vice versa */ + } + if (key) { + const char *n = cbm_node_text(a, name, source); + if (!n || strcmp(n, key) != 0) { + continue; + } + } + return aspnet_string_value(a, value, source); + } + return NULL; +} + +/* [AcceptVerbs("get", ...)]: first verb upper-cased; letters only, since the + * verb lands in the Route QN and its JSON properties unescaped. */ +static const char *aspnet_accept_verb(CBMArena *a, const char *verb) { + enum { VERB_MAX = 7 }; /* OPTIONS */ + size_t len = verb ? strlen(verb) : 0; + if (len == 0 || len > VERB_MAX) { + return "ANY"; + } + char *up = cbm_arena_strndup(a, verb, len); + for (size_t i = 0; i < len; i++) { + if (!isalpha((unsigned char)up[i])) { + return "ANY"; + } + up[i] = (char)toupper((unsigned char)up[i]); + } + return up; +} + +static void aspnet_note_attr(CBMArena *a, TSNode attr, const char *source, aspnet_attrs_t *out) { + const char *name = aspnet_attr_name(a, attr, source); + if (!name) { + return; + } + const char *verb = aspnet_verb(name); + if (verb || strcmp(name, "AcceptVerbs") == 0) { + if (!out->method) { + out->method = + verb ? verb : aspnet_accept_verb(a, aspnet_attr_arg(a, attr, source, NULL)); + out->verb_tmpl = aspnet_attr_arg(a, attr, source, verb ? NULL : "Route"); + } + } else if (strcmp(name, "Route") == 0) { + if (!out->has_route) { + out->has_route = true; + out->route_tmpl = aspnet_attr_arg(a, attr, source, NULL); + } + } else if (strcmp(name, "ActionName") == 0) { + out->action_name = aspnet_attr_arg(a, attr, source, NULL); + } else if (strcmp(name, "Area") == 0) { + out->area = aspnet_attr_arg(a, attr, source, NULL); + } +} + +static void aspnet_scan_attrs(CBMArena *a, TSNode owner, const char *source, aspnet_attrs_t *out) { + memset(out, 0, sizeof(*out)); + TSNode wrappers[MAX_ATTR_WRAPPERS]; + int wn = find_jvm_modifiers(owner, CBM_LANG_CSHARP, wrappers, MAX_ATTR_WRAPPERS); + for (int w = 0; w < wn; w++) { + uint32_t cc = ts_node_named_child_count(wrappers[w]); + for (uint32_t ci = 0; ci < cc; ci++) { + TSNode child = ts_node_named_child(wrappers[w], ci); + if (strcmp(ts_node_type(child), "attribute") == 0) { + aspnet_note_attr(a, child, source, out); + } + } + } +} + +/* `name` minus `suffix` when it is a proper suffix (QuotesController -> + * Quotes, GetAsync -> Get); `name` otherwise. */ +static const char *aspnet_trim_suffix(CBMArena *a, const char *name, const char *suffix) { + size_t len = name ? strlen(name) : 0; + size_t slen = strlen(suffix); + if (len > slen && strcmp(name + len - slen, suffix) == 0) { + return cbm_arena_strndup(a, name, len - slen); + } + return name; +} + +typedef struct { + char *buf; + size_t len; + size_t cap; +} aspnet_buf_t; + +static void aspnet_putc(aspnet_buf_t *b, char c) { + if (c == '/' && b->len > 0 && b->buf[b->len - SKIP_CHAR] == '/') { + return; /* "api/" + "/x" and empty token values never double a slash */ + } + if (b->len + SKIP_CHAR < b->cap) { + b->buf[b->len++] = c; + } +} + +static void aspnet_putn(aspnet_buf_t *b, const char *s, size_t n) { + for (size_t i = 0; i < n && s[i]; i++) { + aspnet_putc(b, s[i]); + } +} + +/* Index just past the '}' closing the '{' at t[open] (braces nest, as in + * "{code:regex(^\d{{3}}$)}"); 0 when unterminated. */ +static size_t aspnet_brace_end(const char *t, size_t open) { + int depth = 0; + for (size_t j = open; t[j]; j++) { + if (t[j] == '{') { + depth++; + } else if (t[j] == '}' && --depth == 0) { + return j + SKIP_CHAR; + } + } + return 0; +} + +/* "{id:int}", "{page=1}", "{slug?}", "{**path}" -> "{id}", "{page}", "{slug}", + * "{**path}": keep the catch-all stars and the parameter name. */ +static void aspnet_put_param(aspnet_buf_t *b, const char *t, size_t open, size_t end) { + size_t i = open + SKIP_CHAR; + aspnet_putc(b, '{'); + while (i < end && t[i] == '*') { + aspnet_putc(b, t[i++]); + } + while (i < end && (isalnum((unsigned char)t[i]) || t[i] == '_')) { + aspnet_putc(b, t[i++]); + } + aspnet_putc(b, '}'); +} + +enum { ASPNET_TOKENS = 3 }; /* [controller], [action], [area] */ + +/* Replacement for the [controller]/[action]/[area] token starting at t[i] + * (values[k] belongs to tokens[k]); NULL when t[i] starts no known token or + * its value is unknown, so the token is kept verbatim. */ +static const char *aspnet_token_value(const char *t, size_t i, const char *const *values, + size_t *tok_len) { + static const char *const tokens[ASPNET_TOKENS] = {"[controller]", "[action]", "[area]"}; + for (size_t k = 0; k < ASPNET_TOKENS; k++) { + size_t n = strlen(tokens[k]); + if (values[k] && strncmp(t + i, tokens[k], n) == 0) { + *tok_len = n; + return values[k]; + } + } + return NULL; +} + +/* Normalize one ASP.NET route template into a '/'-led path (see the section + * comment above). values = {controller, action, area}. */ +static const char *aspnet_route_template(CBMArena *a, const char *t, const char *const *values) { + size_t extra = 0; + for (int k = 0; k < ASPNET_TOKENS; k++) { + extra += values[k] ? strlen(values[k]) : 0; + } + size_t brackets = 0; + for (const char *p = t; *p; p++) { + brackets += *p == '['; + } + aspnet_buf_t b = {0}; + b.cap = strlen(t) + (brackets * extra) + PAIR_CHARS; + b.buf = (char *)cbm_arena_alloc(a, b.cap); + if (!b.buf) { + return NULL; + } + aspnet_putc(&b, '/'); + size_t i = (t[0] == '~') ? SKIP_CHAR : 0; + while (t[i]) { + size_t tok_len = 0; + const char *val = (t[i] == '[') ? aspnet_token_value(t, i, values, &tok_len) : NULL; + size_t end = (t[i] == '{' && t[i + SKIP_CHAR] != '{') ? aspnet_brace_end(t, i) : 0; + if (val) { + aspnet_putn(&b, val, strlen(val)); + i += tok_len; + } else if (end) { + aspnet_put_param(&b, t, i, end); + i = end; + } else if ((t[i] == '{' || t[i] == '}' || t[i] == '[' || t[i] == ']') && + t[i + SKIP_CHAR] == t[i]) { + aspnet_putc(&b, t[i]); /* "{{" / "[[" escapes a literal brace */ + i += PAIR_CHARS; + } else { + aspnet_putc(&b, t[i++]); + } + } + if (b.len > SKIP_CHAR && b.buf[b.len - SKIP_CHAR] == '/') { + b.len--; + } + b.buf[b.len] = '\0'; + return b.buf; +} + +static bool aspnet_template_is_absolute(const char *t) { + return t[0] == '/' || (t[0] == '~' && t[SKIP_CHAR] == '/'); +} + +/* Route of one controller action: sets out_path and out_method when the method + * carries a verb attribute, [AcceptVerbs] or [Route]; clears them otherwise. */ +static void aspnet_action_route(CBMArena *a, TSNode method_node, TSNode class_node, + const char *method_name, const char *source, const char **out_path, + const char **out_method) { + *out_path = NULL; + *out_method = NULL; + aspnet_attrs_t act; + aspnet_scan_attrs(a, method_node, source, &act); + if (!act.method && !act.has_route) { + return; + } + aspnet_attrs_t cls; + aspnet_scan_attrs(a, class_node, source, &cls); + TSNode cname = ts_node_child_by_field_name(class_node, TS_FIELD("name")); + const char *ctrl = ts_node_is_null(cname) ? NULL : cbm_node_text(a, cname, source); + const char *values[ASPNET_TOKENS] = { + aspnet_trim_suffix(a, ctrl, "Controller"), + act.action_name ? act.action_name : aspnet_trim_suffix(a, method_name, "Async"), + act.area ? act.area : cls.area, + }; + const char *tmpl = act.verb_tmpl ? act.verb_tmpl : act.route_tmpl; + const char *path = tmpl ? aspnet_route_template(a, tmpl, values) : NULL; + if ((!tmpl || !aspnet_template_is_absolute(tmpl)) && cls.route_tmpl) { + path = join_route_paths(a, aspnet_route_template(a, cls.route_tmpl, values), path); + } + *out_path = path ? path : "/"; + *out_method = act.method ? act.method : "ANY"; +} + static const char *spring_class_route_prefix(CBMArena *a, TSNode class_node, const char *source, const CBMLangSpec *spec) { const char *map_path = NULL; @@ -8447,6 +8766,10 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, def.route_path = prefix ? join_route_paths(a, prefix, def.route_path) : NULL; def.route_method = prefix ? def.route_method : NULL; } + if (ctx->language == CBM_LANG_CSHARP) { + aspnet_action_route(a, child, class_node, name, ctx->source, &def.route_path, + &def.route_method); + } def.docstring = extract_docstring(ctx, child, name); if (spec->branching_node_types && spec->branching_node_types[0]) { diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index fb0d097a8..ed60d3f9a 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -500,6 +500,11 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const if (props != stack) { cbm_free(CBM_MEM_CLASS_GBUF_STRING, props); } + /* Per definition, like pass_parallel.c: overloads sharing this QN each keep + * their own route (the merged node properties hold only one of them). */ + if (node_id > 0) { + cbm_pipeline_emit_def_route(ctx->gbuf, node_id, def, def_rel); + } /* Registry membership is defined ONCE by cbm_label_is_registry_symbol * (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/ * field resolution), Variable/Field (READS/WRITES resolution), and Table/View diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 450b78a54..89777fd26 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -826,23 +826,7 @@ static void insert_def_into_gbuf(extract_worker_state_t *ws, const cbm_stem_mark cbm_free(CBM_MEM_CLASS_GBUF_STRING, props); } ws->nodes_created++; - if (def->route_path && def->route_path[0] != '\0') { - const char *rm = def->route_method ? def->route_method : "ANY"; - char route_qn[CBM_ROUTE_QN_SIZE]; - char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, - cbm_route_canon_path(def->route_path, cpath, sizeof(cpath))); - char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm); - int64_t route_id = - cbm_gbuf_upsert_node(ws->local_gbuf, "Route", def->route_path, route_qn, - def->file_path ? def->file_path : fi->rel_path, 0, 0, rprops); - char hprops[CBM_SZ_512]; - char esc_h[CBM_SZ_512]; - cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name); - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); - cbm_gbuf_insert_edge(ws->local_gbuf, func_id, route_id, "HANDLES", hprops); - } + cbm_pipeline_emit_def_route(ws->local_gbuf, func_id, def, fi->rel_path); } static void log_extract_fail(int pos, uint64_t ms, const char *path) { diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 303b293b9..18f2b3134 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2833,15 +2833,53 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t static const char php_import_complete[] = "{\"php_imports_complete\":1,"; /* A resolver NULL can also mean unsupported input or a failed lookup. Only - * the bounded namespace-only path admits an allocation-free absence proof. - * A covered-but-missing PSR-4 file, global use, or unavailable namespace map - * leaves the whole file unknown; already resolved IMPORTS are still written. */ -static bool php_import_known_absent(const char *rel, const CBMImport *imp, + * the bounded namespace-only path and an unaliased global class `use` with no + * same-named PHP type anywhere admit an absence proof. A covered-but-missing + * PSR-4 file, any other global use, or an unavailable namespace map leaves the + * whole file unknown; already resolved IMPORTS are still written. */ +/* A global-namespace class `use Exception;` names a project type only when a + * PHP file declares a class-like symbol of that name. With no such node at all + * (namespaced ones included, so the proof stays conservative), the import is + * absent from the project: the PHP builtin or a vendor class. Without this a + * single `use Exception;` left the whole file's evidence unknown, and its + * project imports reached the PHP LSP as File QNs again. */ +static bool php_global_use_known_absent(const cbm_gbuf_t *gbuf, const CBMImport *imp) { + if (!gbuf || imp->kind != CBM_IMPORT_KIND_DEFAULT) { + return false; + } + const char *name = imp->module_path[0] == '\\' ? imp->module_path + 1 : imp->module_path; + if (!name[0] || strcmp(name, imp->local_name) != 0) { + return false; /* `use X as Y;` keeps the old, unknown verdict */ + } + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + if (cbm_gbuf_find_by_name(gbuf, name, &nodes, &count) != 0) { + return false; + } + for (int i = 0; i < count; i++) { + const cbm_gbuf_node_t *n = nodes[i]; + if (!n || !n->label || !node_in_php_file(n)) { + continue; + } + if (strcmp(n->label, "Class") == 0 || strcmp(n->label, "Interface") == 0 || + strcmp(n->label, "Trait") == 0 || strcmp(n->label, "Enum") == 0 || + strcmp(n->label, "File") == 0 || strcmp(n->label, "Module") == 0) { + return false; + } + } + return true; +} + +static bool php_import_known_absent(const cbm_gbuf_t *gbuf, const char *rel, const CBMImport *imp, CBMHashTable *namespace_map) { if (!namespace_map || !imp->module_path || !imp->local_name || !imp->local_name[0] || (imp->kind != CBM_IMPORT_KIND_DEFAULT && imp->kind != CBM_IMPORT_KIND_FUNCTION && - imp->kind != CBM_IMPORT_KIND_CONST) || - php_use_shape(rel, imp) != PHP_USE_NAMESPACED) + imp->kind != CBM_IMPORT_KIND_CONST)) + return false; + php_use_shape_t shape = php_use_shape(rel, imp); + if (shape == PHP_USE_GLOBAL) + return php_global_use_known_absent(gbuf, imp); + if (shape != PHP_USE_NAMESPACED) return false; const char *name = imp->module_path[0] == '\\' ? imp->module_path + 1 : imp->module_path; char key[CBM_SZ_1K]; @@ -3072,7 +3110,7 @@ int cbm_pipeline_php_create_import_edges(cbm_pipeline_ctx_t *ctx, const CBMFileR : NULL; if (targets[i] && targets[i]->id == file->id) targets[i] = NULL; - if (!targets[i] && !php_import_known_absent(rel, imp, namespace_map)) + if (!targets[i] && !php_import_known_absent(ctx->gbuf, rel, imp, namespace_map)) complete = false; yyjson_mut_val *entry = php_binding_json(doc, imp, targets[i] ? targets[i]->qualified_name : NULL); diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index a2134791a..071f20c41 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -397,6 +397,27 @@ static void match_infra_routes(cbm_gbuf_t *gb) { } } +void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefinition *def, + const char *rel) { + if (!def->route_path || def->route_path[0] == '\0') { + return; + } + const char *rm = def->route_method ? def->route_method : "ANY"; + char route_qn[CBM_ROUTE_QN_SIZE]; + char cpath[CBM_SZ_256]; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, + cbm_route_canon_path(def->route_path, cpath, sizeof(cpath))); + char rprops[CBM_SZ_256]; + snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm); + int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", def->route_path, route_qn, + def->file_path ? def->file_path : rel, 0, 0, rprops); + char hprops[CBM_SZ_512]; + char esc_h[CBM_SZ_512]; + cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name); + snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); + cbm_gbuf_insert_edge(gb, func_id, route_id, "HANDLES", hprops); +} + /* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges. * During incremental indexing, only changed files get Route nodes from extraction. * This pass scans ALL Function/Method nodes and creates missing Route+HANDLES. */ diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 5e5cd9506..06722aca7 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -302,6 +302,15 @@ void cbm_pxc_test_import_probe_fail_after(int successful_allocations); /* cbm_route_canon_path: cbm.h (route_path.c). */ +/* A definition's decorator route (def->route_path, e.g. an ASP.NET [HttpGet("x")] + * action): upsert its Route node and the definition -> Route HANDLES edge. Both + * resolvers call it per DEFINITION at node creation (pass_parallel.c, + * pass_definitions.c), so overloads sharing one QN -- a GET form action and its + * POST submit -- each keep their route; the merged node properties hold only one + * of them. Defined in pass_route_nodes.c. */ +void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefinition *def, + const char *rel); + /* A file and the directory beside it share a stem (`pkg/foo.sh` beside * `pkg/foo/`): the file's Module QN equals the directory's Folder QN, and the * graph buffer keeps only the Folder. One side yields: diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index ff6be23f1..4bba54612 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -812,6 +812,126 @@ TEST(handles_aspnet_csharp) { PASS(); } +/* ASP.NET Core attribute routing (C#, #1692) — the reporter's controller shape: + * stacked class attributes with a relative [Route("api/[controller]")] prefix, + * verb attributes with and without a template, and a verb + separate method + * [Route]. Every action got 0 Route nodes: the route extractor knew no + * HttpGet/HttpPost/Route attribute, and relative templates were dropped for lacking a + * leading '/'. The exact Route-name set also pins the [controller] token + * (class name minus "Controller") and the class-prefix join. */ +TEST(handles_aspnet_attribute_routes_issue1692) { + static const char *routes[] = {"/api/Quotes/{symbol}", "/api/Quotes/orders/{id}/confirm", + "/api/Quotes/x/{id}", NULL}; + static const EtFile f[] = { + {"Controllers/QuotesController.cs", + "using Microsoft.AspNetCore.Mvc;\n\n" + "namespace Feeder.Api.Controllers\n{\n" + " [ServiceFilter(typeof(ClientIpCheckFilter))]\n" + " [Route(\"api/[controller]\")]\n" + " [ApiController]\n" + " public class QuotesController : ControllerBase\n {\n" + " [HttpGet(\"{symbol}\")]\n" + " public IActionResult Get(string symbol) { return Ok(symbol); }\n\n" + " [HttpPost]\n" + " [Route(\"orders/{id}/confirm\")]\n" + " public IActionResult Confirm(int id) { return Ok(id); }\n\n" + " [HttpPut(\"x/{id}\")]\n" + " public IActionResult Update(int id) { return Ok(id); }\n }\n}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 3)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + +/* ASP.NET Core (#1041) — the reporter's shape: [ApiController] before a + * versioned [Route("api/v1/[controller]")] and an async [HttpPost("login")] + * action produced no server-side Route node at all. */ +TEST(handles_aspnet_attribute_routes_issue1041) { + static const char *routes[] = {"/api/v1/Auth/login", NULL}; + static const EtFile f[] = { + {"Controllers/AuthController.cs", + "using System.Threading.Tasks;\n" + "using Microsoft.AspNetCore.Mvc;\n\n" + "[ApiController]\n" + "[Route(\"api/v1/[controller]\")]\n" + "public class AuthController : ControllerBase\n{\n" + " [HttpPost(\"login\")]\n" + " public async Task Login(LoginRequest req) { return Ok(); }\n}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 1)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + +/* ASP.NET Core [controller]/[action] token replacement (#1692): the action + * token is the method name ("Async" suffix trimmed, as ASP.NET Core does by + * default) or its [ActionName]; [AcceptVerbs] takes its path from Route=. */ +TEST(handles_aspnet_route_tokens_issue1692) { + static const char *routes[] = {"/api/Items/List", "/api/Items/Details/{id}", + "/api/Items/Find/{name}", "/api/Items/archive", NULL}; + static const EtFile f[] = { + {"ItemsController.cs", "using Microsoft.AspNetCore.Mvc;\n\n" + "[Route(\"api/[controller]\")]\n" + "public class ItemsController : Controller\n{\n" + " [HttpGet(\"[action]\")]\n" + " public IActionResult List() { return View(); }\n\n" + " [HttpGet(\"[action]/{id}\")]\n" + " public System.Threading.Tasks.Task " + "DetailsAsync(int id) { return null; }\n\n" + " [HttpGet(\"[action]/{name}\")]\n" + " [ActionName(\"Find\")]\n" + " public IActionResult Search(string name) { return View(); }\n\n" + " [AcceptVerbs(\"POST\", \"PUT\", Route = \"archive\")]\n" + " public IActionResult Archive() { return View(); }\n}\n"}}; + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + +/* ASP.NET Core absolute override + constraints (#1692): a method template that + * starts with "/" or "~/" ignores the class prefix; route constraints, + * optional markers and defaults are stripped to the bare parameter so the + * Route name matches the clients' "{id}" spelling ("{code:regex(...)}" nests + * braces). A controller without a class [Route] uses the method template as + * the whole path. */ +TEST(handles_aspnet_override_constraints_issue1692) { + static const char *routes[] = { + "/health", "/ping", "/api/Orders/{id}", "/api/Orders/{code}/{page}", "/api/ping", NULL}; + static const EtFile f[] = { + {"OrdersController.cs", + "using Microsoft.AspNetCore.Mvc;\n\n" + "[Route(\"api/[controller]\")]\n" + "public class OrdersController : ControllerBase\n{\n" + " [HttpGet(\"/health\")]\n" + " public IActionResult Health() { return Ok(); }\n\n" + " [Microsoft.AspNetCore.Mvc.HttpHeadAttribute(\"~/ping\")]\n" + " public IActionResult Ping() { return Ok(); }\n\n" + " [HttpDelete(\"{id:int:min(1)}\")]\n" + " public IActionResult Delete(int id) { return Ok(); }\n\n" + " [HttpGet(\"{code:regex(^\\\\d{{3}}$)}/{page=1}\")]\n" + " public IActionResult ByCode(string code, int page) { return Ok(); }\n}\n"}, + {"PingController.cs", "using Microsoft.AspNetCore.Mvc;\n\n" + "public class PingController : ControllerBase\n{\n" + " [HttpGet(\"api/ping\", Name = \"PingRoute\")]\n" + " public IActionResult Get() { return Ok(); }\n}\n"}}; + ASSERT_TRUE(et_routes_exact(f, 2, routes)); + PASS(); +} + +/* Control (#1692): a C# class with ordinary attributes — including string + * arguments that look like paths — must not produce Route nodes. */ +TEST(handles_aspnet_non_controller_control_issue1692) { + static const char *routes[] = {NULL}; + static const EtFile f[] = {{"Helper.cs", "using System;\n\n" + "[Serializable]\n" + "[Description(\"api/helper\")]\n" + "public class Helper\n{\n" + " [Obsolete(\"/use/other\")]\n" + " public void Run() { }\n\n" + " [Fact]\n" + " [Trait(\"Category\", \"api/[controller]\")]\n" + " public void Check() { }\n}\n"}}; + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + /* Laravel (PHP) — Route facade whose QN contains "Laravel". * REAL BUG: internal/cbm/extract_calls.c:extract_handler_arg only accepts an * identifier/member_expression/selector_expression/attribute/field_expression as @@ -1214,6 +1334,34 @@ TEST(data_flows_test_files_pair_within_their_file_parallel) { PASS(); } +/* ASP.NET Core MVC (#1692): a GET form action and its POST submit share one + * method name, so both overloads collapse into one Method node whose merged + * properties keep only one route. The parallel resolver emitted each + * definition's route at node creation and kept both; the sequential one rebuilt + * routes from the merged properties afterwards and kept only the last (28 + * HANDLES on 6 ASP.NET corpora, 2026-10-18). Both venues keep both. */ +TEST(handles_aspnet_overload_verbs_both_venues_issue1692) { + static const EtHandles want[] = {{"AccountController.Login", "/Account/login"}, + {"AccountController.Login", "/Account/login"}, + {NULL, NULL}}; + static const char *const route_qns[] = {"__route__GET__/Account/login", + "__route__POST__/Account/login", NULL}; + static const EtFile f[] = { + {"Controllers/AccountController.cs", + "using Microsoft.AspNetCore.Mvc;\n\n" + "namespace Shop.Web.Controllers\n{\n" + " [Route(\"[controller]\")]\n" + " public class AccountController : Controller\n {\n" + " [HttpGet(\"login\")]\n" + " public IActionResult Login() { return View(); }\n\n" + " [HttpPost(\"login\")]\n" + " public IActionResult Login(LoginModel model) { return Redirect(\"/\"); }\n" + " }\n}\n"}}; + ASSERT_TRUE(et_handles_exact_routes(f, 1, want, 0, route_qns)); + ASSERT_TRUE(et_handles_exact_routes(f, 1, want, 1, route_qns)); + PASS(); +} + /* #1146: Laravel's two class-based handler forms — * Route::get('/x', [UserController::class, 'show']) (controller method) * Route::get('/x', GetCurrentUserController::class) (invokable: __invoke) @@ -2724,6 +2872,12 @@ SUITE(edge_types_probe) { RUN_TEST(handles_spring_java_relative_string_not_route); RUN_TEST(handles_jaxrs_scala); RUN_TEST(handles_aspnet_csharp); + RUN_TEST(handles_aspnet_attribute_routes_issue1692); + RUN_TEST(handles_aspnet_attribute_routes_issue1041); + RUN_TEST(handles_aspnet_route_tokens_issue1692); + RUN_TEST(handles_aspnet_override_constraints_issue1692); + RUN_TEST(handles_aspnet_non_controller_control_issue1692); + RUN_TEST(handles_aspnet_overload_verbs_both_venues_issue1692); RUN_TEST(handles_laravel_php); RUN_TEST(handles_laravel_facade_routes_issue952); RUN_TEST(handles_laravel_facade_no_junk_routes_issue952); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index fc97332e6..a40ccadeb 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -5233,6 +5233,173 @@ TEST(parallel_php_vendor_types_never_bind_by_name_issue1186) { PASS(); } +/* ── PHP LSP types receivers of imported project classes (#1186) ─── */ + +/* Foo.php calls methods on receivers typed with classes imported from OTHER + * project files: a promoted property (`$this->svc->go()`) and an aliased + * import (`R $r` -> `$r->find()`). The import map handed to the PHP LSP held + * the IMPORTS edge target -- the imported file's File node QN + * (`...Svc.php.__file__`) -- as the class, so no receiver ever typed and the + * calls fell to name-only strategies (the #1186 "useful calls at 0.38"). The + * decoy App\Other\Svc shares the short name and the method, so a name-only + * match cannot tell the two apart. Controls: the vendor-typed receiver + * (`Client $c`) must stay unresolved, and the same-file calls keep their LSP + * strategies. */ +static const struct { + const char *rel; + const char *src; +} k_php_xfile_files[] = { + {"lib/Services/Svc.php", "svc->go();\n" + " $r->find();\n" + " $c->go();\n" + " $this->local();\n" + " (new Local())->ping();\n" + " }\n" + " private function local() { return 4; }\n}\n\n" + "class Local {\n" + " public function ping() { return 5; }\n}\n"}, +}; +enum { PHP_XFILE_FILE_COUNT = (int)(sizeof(k_php_xfile_files) / sizeof(k_php_xfile_files[0])) }; + +typedef struct { + const cbm_gbuf_t *gbuf; + const char *src_rel; + const char *tgt_rel; + const char *tgt_suffix; + const char *strategy_prefix; + int total; + int with_strategy; +} php_xfile_edge_count_t; + +static void php_xfile_count_edge(const cbm_gbuf_edge_t *edge, void *ud) { + php_xfile_edge_count_t *c = ud; + if (!edge->type || strcmp(edge->type, "CALLS") != 0) { + return; + } + const cbm_gbuf_node_t *s = cbm_gbuf_find_by_id(c->gbuf, edge->source_id); + const cbm_gbuf_node_t *t = cbm_gbuf_find_by_id(c->gbuf, edge->target_id); + if (!s || !t || !s->file_path || !t->file_path || !t->qualified_name || + strcmp(s->file_path, c->src_rel) != 0 || strcmp(t->file_path, c->tgt_rel) != 0) { + return; + } + size_t qlen = strlen(t->qualified_name); + size_t slen = strlen(c->tgt_suffix); + if (qlen < slen || strcmp(t->qualified_name + qlen - slen, c->tgt_suffix) != 0) { + return; + } + c->total++; + char needle[128]; + snprintf(needle, sizeof(needle), "\"strategy\":\"%s", c->strategy_prefix); + if (edge->properties_json && strstr(edge->properties_json, needle)) { + c->with_strategy++; + } +} + +/* 1 when every cross-file receiver resolved through the PHP LSP, the decoy and + * the vendor receiver drew nothing, and the same-file controls held. */ +static int php_xfile_graph_ok(const cbm_gbuf_t *gbuf, const char *driver) { + static const char *const foo = "lib/Http/Foo.php"; + struct { + const char *tgt; + const char *suffix; + int want_total; + int want_lsp; + } checks[] = { + {"lib/Services/Svc.php", ".Svc.go", 1, 1}, /* $this->svc->go(), not $c->go() */ + {"lib/Other/Svc.php", ".Svc.go", 0, 0}, /* decoy: same short name + method */ + {"lib/Repo/Repo.php", ".Repo.find", 1, 1}, /* aliased import */ + {foo, ".Foo.local", 1, 1}, /* same-file control */ + {foo, ".Local.ping", 1, 1}, /* same-file control */ + }; + int ok = 1; + for (size_t i = 0; i < sizeof(checks) / sizeof(checks[0]); i++) { + php_xfile_edge_count_t c = {gbuf, foo, checks[i].tgt, checks[i].suffix, "php_", 0, 0}; + cbm_gbuf_foreach_edge(gbuf, php_xfile_count_edge, &c); + if (c.total != checks[i].want_total || c.with_strategy != checks[i].want_lsp) { + printf(" [%s] CALLS Foo.php -> %s%s: %d edge(s), %d via php_*; want %d, %d\n", driver, + checks[i].tgt, checks[i].suffix, c.total, c.with_strategy, checks[i].want_total, + checks[i].want_lsp); + ok = 0; + } + } + return ok; +} + +/* Run the fixture on both venues. extra_use (nullable) is one more `use` line + * added to Foo.php. Returns 1 when both graphs pass, 0 when not, -1 on setup + * failure. */ +static int php_xfile_run(const char *extra_use) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_phpxfile_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + return -1; + } + char paths[PHP_XFILE_FILE_COUNT][512]; + cbm_file_info_t files[PHP_XFILE_FILE_COUNT]; + memset(files, 0, sizeof(files)); + static const char foo_use[] = "use Vendor\\Pkg\\Client;\n"; + for (int i = 0; i < PHP_XFILE_FILE_COUNT; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, k_php_xfile_files[i].rel); + char dir[512]; + snprintf(dir, sizeof(dir), "%s", paths[i]); + *strrchr(dir, '/') = '\0'; + cbm_mkdir_p(dir, 0755); + char src[2048]; + const char *s = k_php_xfile_files[i].src; + const char *at = extra_use ? strstr(s, foo_use) : NULL; + if (at) { + size_t head = (size_t)(at - s) + sizeof(foo_use) - 1; + snprintf(src, sizeof(src), "%.*s%s%s", (int)head, s, extra_use, s + head); + s = src; + } + if (th_write_file(paths[i], s) != 0) { + th_rmtree(tmpdir); + return -1; + } + files[i].path = paths[i]; + files[i].rel_path = (char *)k_php_xfile_files[i].rel; + files[i].language = CBM_LANG_PHP; + } + + cbm_gbuf_t *seq = run_sequential_with_lsp_cross_and_mutator( + "cbm_par_phpxfile", tmpdir, files, PHP_XFILE_FILE_COUNT, NULL, NULL, true); + cbm_gbuf_t *par = run_parallel_with_extract_opts_and_mutator( + "cbm_par_phpxfile", tmpdir, files, PHP_XFILE_FILE_COUNT, 2, NULL, NULL, NULL, true); + int seq_ok = seq ? php_xfile_graph_ok(seq, "sequential") : 0; + int par_ok = par ? php_xfile_graph_ok(par, "parallel") : 0; + cbm_gbuf_free(seq); + cbm_gbuf_free(par); + th_rmtree(tmpdir); + return seq_ok && par_ok; +} + +TEST(parallel_php_lsp_types_imported_project_receivers_issue1186) { + ASSERT_EQ(php_xfile_run(NULL), 1); + PASS(); +} + +/* The same file with one more import that no file in the project declares, + * the global class `use Exception;` written in most namespaced PHP files. + * The typed import evidence leaves such a file "unknown" as a whole (#2520), + * so the class-valued import mapping must not depend on that evidence. */ +TEST(parallel_php_lsp_types_imported_project_receivers_unknown_bindings_issue1186) { + ASSERT_EQ(php_xfile_run("use Exception;\n"), 1); + PASS(); +} + + /* A fresh PHP external row replaces stale evidence for its exact occurrence, * including an older external row, without clearing a real project invocation * of the same method at the next occurrence. Test both old-row orders. */ @@ -5346,6 +5513,8 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) { SUITE(parallel) { RUN_TEST(parallel_php_external_row_replaces_only_its_exact_occurrence); RUN_TEST(parallel_php_vendor_types_never_bind_by_name_issue1186); + RUN_TEST(parallel_php_lsp_types_imported_project_receivers_issue1186); + RUN_TEST(parallel_php_lsp_types_imported_project_receivers_unknown_bindings_issue1186); RUN_TEST(usage_semantic_reference_candidate_trusts_marked_producer); RUN_TEST(lsp_bare_segment_skips_preprocessor_spacing); RUN_TEST(lsp_resolve_qualified_static_call_normalizes_colons);