From 36bce46e68af89c9594e8e9b70bc234bee5a8d89 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 01:21:53 +0200 Subject: [PATCH 1/3] fix(go-lsp): cross-file multi-value and named results type their variables The cross-file pass handed every registrar the raw result text of a def (pass_lsp_cross.c: `dst->return_types = src->return_type`). The Go registrar splits that string on '|' (go_lsp.c split_pipe_types), so it got `(*Encoder, error)` as one piece. That piece did not parse, and a cross-file callee with more than one result, or with a named result `(e *Encoder)`, had no usable return type. As a result, `e, err := enc.NewEncoder()`, `h, _ := newHead()` and `_, d := diag.Run()` all left the variable untyped, and calls on it fell to name guesses. Same-file calls were fine, because they read the AST. Go defs now pass the parsed result list (extract_go_multi_return), '|'-joined: `*Encoder|error`, and `*Encoder` for the named result. Other languages keep the single raw string. The persisted LSP surface carries this string, so SURFACE_CODEC_VERSION goes from 2 to 3: existing caches re-extract Go surfaces once. The literal version-2 surfaces in the codec tests move to 3, so that they keep testing the envelope rule they name rather than the version mismatch. Tests: pipeline_go_multi_return_receiver_is_typed (+_parallel). A tuple result, a named result, a tuple-bound local reached through a field, and a named slice type each must bind the right method with an lsp_* strategy and none of its two same-named decoys. Signed-off-by: Martin Vogel --- src/pipeline/lsp_surface.c | 4 +- src/pipeline/pass_lsp_cross.c | 15 ++- tests/test_pipeline.c | 187 +++++++++++++++++++++++++++++++--- 3 files changed, 189 insertions(+), 17 deletions(-) diff --git a/src/pipeline/lsp_surface.c b/src/pipeline/lsp_surface.c index 67bffd5f79..308fcf9590 100644 --- a/src/pipeline/lsp_surface.c +++ b/src/pipeline/lsp_surface.c @@ -33,7 +33,9 @@ #include "pipeline/worker_pool.h" #include "yyjson/yyjson.h" -enum { SURFACE_CODEC_VERSION = 2 }; +/* 3: Go defs carry their parsed result list `|`-joined in "ret" (F-G8 D1); + * a version-2 surface holds the raw result text and must be re-extracted. */ +enum { SURFACE_CODEC_VERSION = 3 }; /* Labels the incremental name registry serves that pxc_map_label does NOT * carry into the CBMLSPDef set. Their (name, qn, label) triple must still diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 6d99ff794a..5009d3187b 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -513,10 +513,17 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch src->decl_namespace ? cbm_arena_strdup(arena, src->decl_namespace) : NULL; } dst->is_interface = (strcmp(label, "Interface") == 0 || strcmp(label, "Protocol") == 0); - /* Single return-type string. The per-language registrars split on '|' - * for multi-return languages (Go); single-return languages just see one - * piece, which is what's already stored. */ - dst->return_types = src->return_type ? cbm_arena_strdup(arena, src->return_type) : NULL; + /* The return-type string. The Go registrar splits it on '|' (go_lsp.c + * split_pipe_types), so Go gets the PARSED result list, joined: `(*T, + * error)` arrives as `*T|error` and a named result `(t *T)` as `*T`. The + * raw result text it used to get did not parse, so every variable bound + * by a cross-file multi-value or named-result call (`t, err := pkg.New()`) + * stayed untyped (F-G8 D1). Other languages keep the single raw string. */ + if (lang == CBM_LANG_GO && src->return_types && src->return_types[0]) { + dst->return_types = pxc_join_pipe(arena, src->return_types); + } else { + dst->return_types = src->return_type ? cbm_arena_strdup(arena, src->return_type) : NULL; + } /* Languages whose cross registrars read embedded_types as QNs get their * bases resolved against the project registry; everyone else keeps the * raw source spelling their own registrar already knows how to handle. */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9a756f688e..d9eb9e3fb8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8819,6 +8819,167 @@ static void write_go_c_ref_guard_fixture(const char *tmp, int pad_files) { } } +/* F-G8 D1: a variable bound by a CROSS-file multi-value or named-result call + * is typed. The cross-file registrar received the raw result text + * `(*Encoder, error)`, which did not parse, so `e, err := enc.NewEncoder()` + * left e untyped and e.EncodeTuple() fell to a name guess. Every target + * method has two same-named decoys, so a guess cannot pass. pad_files > 0 + * forces the parallel resolver. */ +static void write_go_multi_return_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fx\n\ngo 1.22\n"); + write_temp_file(tmp, "enc/enc.go", + "package enc\n" + "\n" + "type Encoder struct{ n int }\n" + "\n" + "func NewEncoder() (*Encoder, error) { return &Encoder{}, nil }\n" + "\n" + "func NewNamed() (e *Encoder) { e = &Encoder{}; return }\n" + "\n" + "func (e *Encoder) EncodeTuple() int { return e.n }\n" + "\n" + "func (e *Encoder) EncodeNamed() int { return e.n }\n"); + write_temp_file(tmp, "diag/diag.go", + "package diag\n" + "\n" + "type Diagnostic struct{ msg string }\n" + "\n" + "type Diagnostics []Diagnostic\n" + "\n" + "func (d Diagnostics) HasErrorsSlice() bool { return len(d) > 0 }\n" + "\n" + "func Run() (int, Diagnostics) { return 0, nil }\n"); + write_temp_file(tmp, "app/types.go", + "package app\n" + "\n" + "type isolation struct{}\n" + "\n" + "func (i *isolation) LowWatermarkField() int { return 0 }\n" + "\n" + "type Head struct{ iso *isolation }\n" + "\n" + "func newHead() (*Head, error) { return &Head{iso: &isolation{}}, nil }\n"); + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "import (\n" + "\t\"example.com/fx/diag\"\n" + "\t\"example.com/fx/enc\"\n" + ")\n" + "\n" + "func UseTuple() int {\n" + "\te, err := enc.NewEncoder()\n" + "\t_ = err\n" + "\treturn e.EncodeTuple()\n" + "}\n" + "\n" + "func UseNamed() int {\n" + "\te := enc.NewNamed()\n" + "\treturn e.EncodeNamed()\n" + "}\n" + "\n" + "func UseField() int {\n" + "\th, _ := newHead()\n" + "\treturn h.iso.LowWatermarkField()\n" + "}\n" + "\n" + "func UseSlice() bool {\n" + "\t_, d := diag.Run()\n" + "\treturn d.HasErrorsSlice()\n" + "}\n"); + write_temp_file(tmp, "decoy/decoy.go", + "package decoy\n" + "\n" + "type D1 struct{}\n" + "\n" + "type D2 struct{}\n" + "\n" + "func (D1) EncodeTuple() int { return 0 }\n" + "func (D2) EncodeTuple() int { return 0 }\n" + "func (D1) EncodeNamed() int { return 0 }\n" + "func (D2) EncodeNamed() int { return 0 }\n" + "func (D1) LowWatermarkField() int { return 0 }\n" + "func (D2) LowWatermarkField() int { return 0 }\n" + "func (D1) HasErrorsSlice() bool { return false }\n" + "func (D2) HasErrorsSlice() bool { return false }\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler/filler%d.go", i); + snprintf(body, sizeof(body), "package filler\n\nfunc Filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +static int go_multi_return_case(int pad_files) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_multiret_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_multi_return_fixture(tmp, pad_files); + char *saved = NULL; + if (pad_files > 0) { + char *old_workers = getenv("CBM_WORKERS"); + saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_multiret.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + if (pad_files > 0) { + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + } + ASSERT_NOT_NULL(p); + ASSERT_EQ(rc, 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + static const struct { + const char *caller; + const char *method; + const char *file; + } want[] = { + {"UseTuple", "EncodeTuple", "enc/enc.go"}, /* e, err := enc.NewEncoder() */ + {"UseNamed", "EncodeNamed", "enc/enc.go"}, /* named result (e *Encoder) */ + {"UseField", "LowWatermarkField", "app/types.go"}, /* h, _ := newHead(); h.iso.X() */ + {"UseSlice", "HasErrorsSlice", "diag/diag.go"}, /* _, d := diag.Run() */ + }; + for (size_t i = 0; i < sizeof(want) / sizeof(want[0]); i++) { + int right = named_edge_to_file_count(s, project, "CALLS", want[i].caller, want[i].method, + want[i].file); + int decoy = named_edge_to_file_count(s, project, "CALLS", want[i].caller, want[i].method, + "decoy/decoy.go"); + bool typed = cross_file_call_has_strategy(s, project, want[i].caller, want[i].method, + "\"strategy\":\"lsp_"); + if (right < 1 || decoy != 0 || !typed) { + printf(" %s -> %s: right=%d decoy=%d lsp=%d\n", want[i].caller, want[i].method, right, + decoy, typed); + FAIL("multi-value / named-result receiver not typed"); + } + } + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_go_multi_return_receiver_is_typed) { + return go_multi_return_case(0); +} + +TEST(pipeline_go_multi_return_receiver_is_typed_parallel) { + return go_multi_return_case(52); +} + TEST(pipeline_go_rw_usage_never_cross_into_c) { /* #1928: USAGE and WRITES/READS resolve through the same short-name * registry as CALLS but never consulted the #725 cross-language guard. @@ -21988,7 +22149,7 @@ TEST(pipeline_python_namespace_change_admission) { } int body = -9, signature = -9, alias = -9, order = -9; int empty = -9, literal = -9, unknown = -9, deletion = -9, transition = -9; - const char *non_python = "{\"v\":2,\"lang\":0,\"lsp\":[],\"reg\":[],\"py\":null}"; + const char *non_python = "{\"v\":3,\"lang\":0,\"lsp\":[],\"reg\":[],\"py\":null}"; if (complete) { body = cbm_lsp_surface_python_change(json[0], json[1]); signature = cbm_lsp_surface_python_change(json[0], json[2]); @@ -22115,22 +22276,22 @@ TEST(pipeline_python_namespace_codec_rejects_non_authoritative_rows) { * Even an empty LSP array must validate its namespace envelope. */ const char *bad[] = { "{\"v\":1,\"lsp\":[]}", - "{\"v\":2,\"lang\":1,\"lsp\":[]}", - "{\"v\":2,\"lang\":1,\"lsp\":[],\"py\":null}", - "{\"v\":2,\"lang\":1.0,\"lsp\":[],\"py\":null}", - "{\"v\":2,\"v\":2,\"lang\":0,\"lsp\":[],\"py\":null}", - "{\"v\":2,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," + "{\"v\":3,\"lang\":1,\"lsp\":[]}", + "{\"v\":3,\"lang\":1,\"lsp\":[],\"py\":null}", + "{\"v\":3,\"lang\":1.0,\"lsp\":[],\"py\":null}", + "{\"v\":3,\"v\":3,\"lang\":0,\"lsp\":[],\"py\":null}", + "{\"v\":3,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," "\"module\":\"p.m\",\"path\":\"m.py\",\"status\":3,\"failure\":2,\"events\":[]}}", - "{\"v\":2,\"lang\":1,\"lsp\":[],\"py\":{\"v\":99,\"lang\":1," + "{\"v\":3,\"lang\":1,\"lsp\":[],\"py\":{\"v\":99,\"lang\":1," "\"module\":\"p.m\",\"path\":\"m.py\",\"status\":2,\"failure\":0,\"events\":[]}}", - "{\"v\":2,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," + "{\"v\":3,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," "\"module\":\"p.m\",\"path\":\"m.py\\u0000suffix\",\"status\":2," "\"failure\":0,\"events\":[]}}", - "{\"v\":2,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," + "{\"v\":3,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," "\"module\":\"p.m\",\"path\":\"m.py\",\"status\":2,\"failure\":0,\"events\":[" "{\"k\":999,\"d\":0,\"s\":0,\"r\":0,\"f\":0,\"l\":0," "\"n\":null,\"m\":null,\"i\":null,\"a\":[]}]}}", - "{\"v\":2,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," + "{\"v\":3,\"lang\":1,\"lsp\":[],\"py\":{\"v\":1,\"lang\":1," "\"module\":\"p.m\",\"path\":\"m.py\",\"status\":2,\"failure\":0,\"events\":[" "{\"k\":8,\"d\":0,\"s\":1,\"r\":0,\"f\":0,\"l\":4294967296," "\"n\":null,\"m\":null,\"i\":null,\"a\":[]}]}}", @@ -22150,7 +22311,7 @@ TEST(pipeline_python_namespace_codec_rejects_non_authoritative_rows) { cbm_arena_destroy(&arena); } const char *unknown = - "{\"v\":2,\"lang\":1,\"lsp\":[],\"reg\":[],\"py\":{\"v\":1,\"lang\":1," + "{\"v\":3,\"lang\":1,\"lsp\":[],\"reg\":[],\"py\":{\"v\":1,\"lang\":1," "\"module\":\"p.m\",\"path\":\"m.py\",\"status\":2,\"failure\":0,\"events\":[" "{\"k\":7,\"d\":0,\"s\":0,\"r\":7,\"f\":0,\"l\":0," "\"n\":null,\"m\":null,\"i\":null,\"a\":[]}]}}"; @@ -22177,7 +22338,7 @@ TEST(pipeline_lsp_surface_large_object_array_decode) { const size_t capacity = (size_t)count * 192 + 64; char *json = malloc(capacity); ASSERT_NOT_NULL(json); - size_t used = (size_t)snprintf(json, capacity, "{\"v\":2,\"lang\":0,\"py\":null,\"lsp\":["); + size_t used = (size_t)snprintf(json, capacity, "{\"v\":3,\"lang\":0,\"py\":null,\"lsp\":["); for (int i = 0; i < count; i++) { int n = snprintf(json + used, capacity - used, "%s{\"qn\":\"pkg.f%d\",\"sn\":\"f%d\",\"lb\":\"Function\"," @@ -24830,6 +24991,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_keeps_specific_unique_name_member_call); RUN_TEST(pipeline_html_embedded_member_call_stays_unbound); + RUN_TEST(pipeline_go_multi_return_receiver_is_typed); + RUN_TEST(pipeline_go_multi_return_receiver_is_typed_parallel); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_rust_call_never_binds_another_language); From 9129c48f4832d295a914547ee11907d9a6500cd3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 02:55:24 +0200 Subject: [PATCH 2/3] test(pipeline): the surface envelope probe expects codec version 3 The Python surface-envelope test (pipeline round 0) checks that the persisted row is the versioned codec envelope by looking for the literal "v":2. With SURFACE_CODEC_VERSION at 3, the envelope reads "v":3. The assertion keeps its meaning (the row is the versioned envelope and the def is present) and follows the version. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d9eb9e3fb8..b7f2b5cff5 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -22428,9 +22428,10 @@ TEST(pipeline_lsp_surface_persisted_and_body_edit_invariant) { ASSERT_TRUE(row_a->surface_sha && row_a->surface_sha[0]); if (round == 0) { snprintf(sha_baseline, sizeof(sha_baseline), "%s", row_a->surface_sha); - /* The row is the versioned codec envelope with the def present. */ + /* The row is the versioned codec envelope (codec version 3) with + * the def present. */ snprintf(json_probe, sizeof(json_probe), "%.20s", row_a->defs_json); - ASSERT_TRUE(strstr(row_a->defs_json, "\"v\":2") != NULL); + ASSERT_TRUE(strstr(row_a->defs_json, "\"v\":3") != NULL); ASSERT_TRUE(strstr(row_a->defs_json, "surface_probe") != NULL); } else if (round == 1) { ASSERT_STR_EQ(row_a->surface_sha, sha_baseline); From bec015461c7a034b201112d249afcfecd9927117 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 03:29:05 +0200 Subject: [PATCH 3/3] fix(go-lsp): split the raw Go result list, keep a single result raw The first D1 change fed the Go cross-file registrar the extractor's return_types. Those are cleaned names for type-reference edges: clean_type_name strips `[]` and `*`. A cross-file single result `[]T` therefore reached the registrar as `T`. `xs := pkg.Generate(); xs[0].M()` lost its typing and fell back to a name guess. On prometheus the base binds `sample[0].T()` to chunks.Sample.T, but the first D1 change bound it to tsdb.T. pass_lsp_cross.c now leaves a single result's raw text alone, as before D1; the registrar already parses `*T` and `[]T`. Only a result LIST `(...)` is split, at its top-level commas, into raw element types: - `(*T, error)` -> `*T|error`; - the named `(t *T, err error)` -> `*T|error`; - the grouped `(a, b int)` -> `int|int`. `chan`, `func`, `map`, `struct` and `interface` never count as result names. A list that does not split cleanly keeps its raw text. Tests: the D1 fixture gains three call sites, each with two same-named decoys: - a single slice result indexed (`xs[0].TS()`); - a tuple with a slice element; - grouped named results. Signed-off-by: Martin Vogel --- src/pipeline/pass_lsp_cross.c | 175 ++++++++++++++++++++++++++++++++-- tests/test_pipeline.c | 51 +++++++++- 2 files changed, 214 insertions(+), 12 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5009d3187b..1d0f7468b6 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -166,6 +166,163 @@ static const char *pxc_join_pipe(CBMArena *arena, const char *const *items) { return buf; } +/* ── Go result lists for the cross-file registrar ───────────────── + * + * The Go registrar reads a def's return_types as '|'-separated type texts and + * parses each piece on its own (go_lsp.c split_pipe_types -> + * cbm_parse_return_type_text: `*T`, `[]T`, builtins, named types). A single + * result already is one such text. A result LIST arrives as its raw source, + * `(*T, error)` or the named `(t *T, err error)`, which parses as nothing, so + * pxc_go_result_list splits it into its element types. The extractor's + * return_types are no substitute: clean_type_name strips `[]` and `*` for + * type-reference edges, and `[]T` read as `T` mistypes `xs[0].M()`. */ + +enum { PXC_GO_MAX_RESULTS = 16 }; + +typedef struct { + const char *text; + size_t len; +} pxc_span_t; + +static bool pxc_is_space(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; +} + +static bool pxc_is_ident_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +static int pxc_go_depth(char c, int depth) { + if (c == '(' || c == '[' || c == '{') { + return depth + 1; + } + if ((c == ')' || c == ']' || c == '}') && depth > 0) { + return depth - 1; + } + return depth; +} + +static pxc_span_t pxc_trim(const char *s, size_t n) { + while (n > 0 && pxc_is_space(*s)) { + s++; + n--; + } + while (n > 0 && pxc_is_space(s[n - 1])) { + n--; + } + return (pxc_span_t){s, n}; +} + +/* Split `(a, b)` at its top-level commas into trimmed elements. A trailing + * comma (a multi-line list) leaves no element. Returns the element count, or + * -1 for text that is no result list or holds an empty element. */ +static int pxc_go_split_results(const char *raw, pxc_span_t *out) { + size_t n = strlen(raw); + if (n < 2 || raw[0] != '(' || raw[n - 1] != ')') { + return -1; + } + const char *s = raw + 1; + size_t len = n - 2; + int count = 0; + int depth = 0; + size_t start = 0; + for (size_t i = 0; i <= len; i++) { + bool end = i == len; + if (!end && (s[i] != ',' || depth != 0)) { + depth = pxc_go_depth(s[i], depth); + continue; + } + pxc_span_t e = pxc_trim(s + start, i - start); + if (e.len == 0 && !(end && count > 0)) { + return -1; + } + if (e.len > 0) { + if (count == PXC_GO_MAX_RESULTS) { + return -1; + } + out[count++] = e; + } + start = i + 1; + } + return count; +} + +/* Length of the result NAME that starts an element (`err` in `err error`), + * 0 when the element is a bare type. A type keyword is never a name: `chan T` + * and `func() T` are types. *lone is set when the whole element is one + * identifier: a type in `(T, error)`, a grouped name in `(a, b int)`. */ +static size_t pxc_go_result_name(pxc_span_t e, bool *lone) { + size_t k = 0; + while (k < e.len && pxc_is_ident_char(e.text[k])) { + k++; + } + *lone = k > 0 && k == e.len; + if (k == 0 || k == e.len || !pxc_is_space(e.text[k])) { + return 0; + } + static const char *const type_keywords[] = {"chan", "func", "map", "struct", "interface", NULL}; + for (int i = 0; type_keywords[i]; i++) { + if (strlen(type_keywords[i]) == k && memcmp(e.text, type_keywords[i], k) == 0) { + return 0; + } + } + return k; +} + +/* Named results: each lone name takes the type of the next `name type` + * element, as Go groups them. Returns count, or -1 when the list is not a + * well-formed named list. */ +static int pxc_go_named_types(CBMArena *arena, const pxc_span_t *el, int count, + const char **types) { + int pending = 0; + for (int i = 0; i < count; i++) { + bool lone = false; + size_t k = pxc_go_result_name(el[i], &lone); + if (k == 0) { + if (!lone) { + return -1; + } + pending++; + continue; + } + pxc_span_t t = pxc_trim(el[i].text + k, el[i].len - k); + const char *type = cbm_arena_sprintf(arena, "%.*s", (int)t.len, t.text); + for (int j = i - pending; j <= i; j++) { + types[j] = type; + } + pending = 0; + } + return pending == 0 ? count : -1; +} + +/* `(*T, error)` -> `*T|error`; `(t *T, err error)` -> `*T|error`; + * `(a, b int)` -> `int|int`. NULL when raw is no result list (a single + * result) or does not split cleanly: the caller keeps the raw text then. */ +static const char *pxc_go_result_list(CBMArena *arena, const char *raw) { + pxc_span_t el[PXC_GO_MAX_RESULTS]; + int count = pxc_go_split_results(raw, el); + if (count <= 0) { + return NULL; + } + bool named = false; + for (int i = 0; i < count && !named; i++) { + bool lone = false; + named = pxc_go_result_name(el[i], &lone) > 0; + } + const char *types[PXC_GO_MAX_RESULTS + 1]; + if (named) { + if (pxc_go_named_types(arena, el, count, types) < 0) { + return NULL; + } + } else { + for (int i = 0; i < count; i++) { + types[i] = cbm_arena_sprintf(arena, "%.*s", (int)el[i].len, el[i].text); + } + } + types[count] = NULL; + return pxc_join_pipe(arena, types); +} + /* ── Cross-file base-class QN resolution ────────────────────────── * * CBMDefinition.base_classes carries the SOURCE SPELLING of each base @@ -514,13 +671,17 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch } dst->is_interface = (strcmp(label, "Interface") == 0 || strcmp(label, "Protocol") == 0); /* The return-type string. The Go registrar splits it on '|' (go_lsp.c - * split_pipe_types), so Go gets the PARSED result list, joined: `(*T, - * error)` arrives as `*T|error` and a named result `(t *T)` as `*T`. The - * raw result text it used to get did not parse, so every variable bound - * by a cross-file multi-value or named-result call (`t, err := pkg.New()`) - * stayed untyped (F-G8 D1). Other languages keep the single raw string. */ - if (lang == CBM_LANG_GO && src->return_types && src->return_types[0]) { - dst->return_types = pxc_join_pipe(arena, src->return_types); + * split_pipe_types), so a Go result LIST arrives split into its element + * types: `(*T, error)` as `*T|error`, a named `(t *T)` as `*T`. The raw + * list text did not parse, so every variable bound by a cross-file + * multi-value or named-result call (`t, err := pkg.New()`) stayed untyped + * (F-G8 D1). A single result, and every other language, keeps the raw + * text the registrar already parses (`[]T` stays a slice). */ + const char *go_list = (lang == CBM_LANG_GO && src->return_type) + ? pxc_go_result_list(arena, src->return_type) + : NULL; + if (go_list) { + dst->return_types = go_list; } else { dst->return_types = src->return_type ? cbm_arena_strdup(arena, src->return_type) : NULL; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b7f2b5cff5..5ee606e60d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8822,9 +8822,11 @@ static void write_go_c_ref_guard_fixture(const char *tmp, int pad_files) { /* F-G8 D1: a variable bound by a CROSS-file multi-value or named-result call * is typed. The cross-file registrar received the raw result text * `(*Encoder, error)`, which did not parse, so `e, err := enc.NewEncoder()` - * left e untyped and e.EncodeTuple() fell to a name guess. Every target - * method has two same-named decoys, so a guess cannot pass. pad_files > 0 - * forces the parallel resolver. */ + * left e untyped and e.EncodeTuple() fell to a name guess. A single result + * keeps its raw text: `[]Sample` must stay a slice so that `xs[0].TS()` is + * typed (the extractor's cleaned return_types read it as `Sample`). Every + * target method has two same-named decoys, so a guess cannot pass. + * pad_files > 0 forces the parallel resolver. */ static void write_go_multi_return_fixture(const char *tmp, int pad_files) { write_temp_file(tmp, "go.mod", "module example.com/fx\n\ngo 1.22\n"); write_temp_file(tmp, "enc/enc.go", @@ -8836,9 +8838,25 @@ static void write_go_multi_return_fixture(const char *tmp, int pad_files) { "\n" "func NewNamed() (e *Encoder) { e = &Encoder{}; return }\n" "\n" + "func NewPair() (a, b *Encoder) { return &Encoder{}, &Encoder{} }\n" + "\n" "func (e *Encoder) EncodeTuple() int { return e.n }\n" "\n" - "func (e *Encoder) EncodeNamed() int { return e.n }\n"); + "func (e *Encoder) EncodeNamed() int { return e.n }\n" + "\n" + "func (e *Encoder) EncodeGrouped() int { return e.n }\n"); + write_temp_file(tmp, "samples/samples.go", + "package samples\n" + "\n" + "type Sample interface{ TS() int64 }\n" + "\n" + "type sample struct{}\n" + "\n" + "func (sample) TS() int64 { return 0 }\n" + "\n" + "func Generate() []Sample { return []Sample{sample{}} }\n" + "\n" + "func List() ([]Sample, error) { return []Sample{sample{}}, nil }\n"); write_temp_file(tmp, "diag/diag.go", "package diag\n" "\n" @@ -8865,8 +8883,24 @@ static void write_go_multi_return_fixture(const char *tmp, int pad_files) { "import (\n" "\t\"example.com/fx/diag\"\n" "\t\"example.com/fx/enc\"\n" + "\t\"example.com/fx/samples\"\n" ")\n" "\n" + "func UseIndex() int64 {\n" + "\txs := samples.Generate()\n" + "\treturn xs[0].TS()\n" + "}\n" + "\n" + "func UseTupleSlice() int64 {\n" + "\tys, _ := samples.List()\n" + "\treturn ys[0].TS()\n" + "}\n" + "\n" + "func UseGrouped() int {\n" + "\tx, _ := enc.NewPair()\n" + "\treturn x.EncodeGrouped()\n" + "}\n" + "\n" "func UseTuple() int {\n" "\te, err := enc.NewEncoder()\n" "\t_ = err\n" @@ -8901,7 +8935,11 @@ static void write_go_multi_return_fixture(const char *tmp, int pad_files) { "func (D1) LowWatermarkField() int { return 0 }\n" "func (D2) LowWatermarkField() int { return 0 }\n" "func (D1) HasErrorsSlice() bool { return false }\n" - "func (D2) HasErrorsSlice() bool { return false }\n"); + "func (D2) HasErrorsSlice() bool { return false }\n" + "func (D1) EncodeGrouped() int { return 0 }\n" + "func (D2) EncodeGrouped() int { return 0 }\n" + "func (D1) TS() int64 { return 0 }\n" + "func (D2) TS() int64 { return 0 }\n"); for (int i = 0; i < pad_files; i++) { char name[64]; char body[128]; @@ -8951,6 +8989,9 @@ static int go_multi_return_case(int pad_files) { {"UseNamed", "EncodeNamed", "enc/enc.go"}, /* named result (e *Encoder) */ {"UseField", "LowWatermarkField", "app/types.go"}, /* h, _ := newHead(); h.iso.X() */ {"UseSlice", "HasErrorsSlice", "diag/diag.go"}, /* _, d := diag.Run() */ + {"UseGrouped", "EncodeGrouped", "enc/enc.go"}, /* (a, b *Encoder) */ + {"UseIndex", "TS", "samples/samples.go"}, /* single []Sample, xs[0] */ + {"UseTupleSlice", "TS", "samples/samples.go"}, /* ([]Sample, error) */ }; for (size_t i = 0; i < sizeof(want) / sizeof(want[0]); i++) { int right = named_edge_to_file_count(s, project, "CALLS", want[i].caller, want[i].method,