Skip to main content

bynk_ide/
completion.rs

1//! Completion for the cursor, keyed off the line up to it.
2//!
3//! The surface is the canonical *cursor context × candidate-kind* matrix fixed
4//! by ADR 0093 (`design/decisions/0093-completion-surface-contract.md`), spec'd
5//! at `design/bynk-lsp-spec.md` §3.15. [`complete`] dispatches the six contexts
6//! it can serve purely (no analysis cache):
7//!
8//! - `consumes <prefix>` / `consumes U { … }` / `given …` — consumable units and
9//!   in-scope capabilities (v0.17);
10//! - **type position** (`: T`, `-> T`, inside `[ … ]` type args) — built-in
11//!   types, the `bynk`-surface transparent types, and project `type` decls;
12//! - **keyword position** (a bare word at a declaration/statement start) — the
13//!   reserved keywords (with registry docs) and declaration snippets;
14//! - **name-receiver `UpperIdent.`** — sum variants (project + built-in
15//!   `HttpResult`/`QueueResult`), refined/opaque `of`/`unsafe`, capability ops,
16//!   and built-in type statics (`Int.parse`/`List.empty`/`Effect.pure`/…);
17//! - **expression position** (after `=`/`(`/`,`/`=>`/an operator) — the value
18//!   constructors (`Ok`/`Some`/`true`/…), in-scope type names, and in-scope free
19//!   functions (the current unit's own `fn`s + `uses`-imported stdlib/project
20//!   combinators, gated on the `uses` set) (ADR 0093 D3).
21//!
22//! Two further contexts need the analysis overlay and so live handler-side
23//! (`main.rs`): **value-receiver `lower.`** members (kernel methods + record
24//! fields) and **in-scope locals/params**. They depend on the analysis overlay
25//! (the boundary is ADR 0093 D4), but since slice 4 (ADR 0094) it is
26//! error-tolerant: best-effort partial types are recorded even on a broken
27//! buffer, so they no longer go silent on an unrelated error. Items also carry a
28//! one-line `detail` eagerly; the richer `documentation` is filled in lazily by
29//! `completionItem/resolve`, handler-side (slice 5).
30//!
31//! Context detection is lexical (it must work mid-edit, when the buffer rarely
32//! parses); candidates are semantic. Unit/type/capability/member enumeration
33//! parses the project's `.bynk` files (and the embedded `bynk` surface) with
34//! recovery, so it works even while the file the cursor sits in is mid-edit.
35//! Built-ins, keywords, and constructors come from the static `bynkc` registries
36//! (`keywords`/`builtin_names`/`firstparty`/`ast`), never the index — first-party
37//! symbols aren't indexed (the v0.28 finding); the project parse supplies only
38//! *project* symbols.
39
40use std::collections::{BTreeSet, HashMap};
41use std::path::{Path, PathBuf};
42use std::sync::{Arc, LazyLock, Mutex};
43
44use bynk_check::checker::{NamedKind, Ty, TyId, Types};
45use bynk_check::kernel_methods;
46use bynk_check::locals::LocalBinding;
47use bynk_check::store_ops;
48use bynk_syntax::ast::{CommonsItem, ExportKind, FnName, SourceUnit, TypeBody, TypeRef, UsesDecl};
49use bynk_syntax::{keywords, lexer, parser};
50
51use crate::symbols::type_ref_str;
52
53/// What a candidate refers to — maps to an LSP `CompletionItemKind`.
54#[derive(Clone, Copy, PartialEq, Eq)]
55pub enum CompletionKind {
56    Unit,
57    Capability,
58    Type,
59    Keyword,
60    Snippet,
61    /// A sum-type variant (`Color.Red`).
62    Variant,
63    /// A name-receiver member: a refined/opaque `of`/`unsafe` constructor, a
64    /// capability operation, or a built-in type static (`Int.parse`).
65    Member,
66    /// A record field on a value receiver (`order.total`).
67    Field,
68    /// A value constructor at expression position (`Ok`/`Some`/`true`).
69    Constructor,
70    /// A free function in scope at expression position — the current unit's own
71    /// top-level `fn`s and the `uses`-imported stdlib/project combinators.
72    Function,
73}
74
75pub struct Completion {
76    pub label: String,
77    pub kind: CompletionKind,
78    pub detail: Option<String>,
79    /// LSP snippet text (with `${n:…}`/`$0` tab stops) for `Snippet` items;
80    /// `None` means insert the label verbatim.
81    pub insert_text: Option<String>,
82}
83
84impl Completion {
85    pub fn item(label: impl Into<String>, kind: CompletionKind, detail: Option<String>) -> Self {
86        Completion {
87            label: label.into(),
88            kind,
89            detail,
90            insert_text: None,
91        }
92    }
93
94    fn snippet(label: &str, body: &str) -> Self {
95        Completion {
96            label: label.to_string(),
97            kind: CompletionKind::Snippet,
98            detail: Some(format!("{label} scaffold")),
99            insert_text: Some(body.to_string()),
100        }
101    }
102}
103
104/// Produce completions for the cursor, given the text of the line up to the
105/// cursor, the current document text, and the project source root (if any).
106pub fn complete(
107    line_prefix: &str,
108    doc_text: &str,
109    files: Option<&HashMap<PathBuf, String>>,
110) -> Vec<Completion> {
111    // 1. Inside `consumes U { … <cursor>` — the capabilities U exports.
112    if let Some(unit) = consumes_brace_unit(line_prefix) {
113        return capabilities_of_unit(&unit, doc_text, files)
114            .into_iter()
115            .map(|c| {
116                Completion::item(
117                    c,
118                    CompletionKind::Capability,
119                    Some(format!("capability exported by `{unit}`")),
120                )
121            })
122            .collect();
123    }
124    // 2. After `consumes <prefix>` — consumable unit names.
125    if is_consumes_target(line_prefix) {
126        return consumable_units(doc_text, files);
127    }
128    // 3. After `given …` — in-scope capabilities.
129    if is_given_position(line_prefix) {
130        return in_scope_capabilities(doc_text, files);
131    }
132    // 4. `UpperIdent.<cursor>` — name-receiver members: sum variants, refined/
133    //    opaque `of`/`unsafe`, capability ops, or built-in type statics.
134    if let Some(receiver) = member_receiver(line_prefix) {
135        return member_candidates(&receiver, doc_text, files);
136    }
137    // v0.124 (slice 3): the non-keyword clause/construction contexts, before the
138    // generic type/keyword/expression cells they would otherwise fall into.
139    // 4a. `Type { <cursor>` — record field names on construction.
140    if let Some(recv) = record_construction_receiver(line_prefix) {
141        let fields = record_field_names(&recv, doc_text, files);
142        if !fields.is_empty() {
143            return fields;
144        }
145    }
146    // 4b. `from <cursor>` — the service protocols.
147    if after_clause_keyword(line_prefix, "from") {
148        return protocol_candidates();
149    }
150    // 4c. `on <cursor>` — the handler kinds.
151    if after_clause_keyword(line_prefix, "on") {
152        return handler_kind_candidates();
153    }
154    // 4d. `by <cursor>` — the project's actor names.
155    if after_clause_keyword(line_prefix, "by") {
156        return actor_candidates(doc_text, files);
157    }
158    // 4e. `exports <cursor>` — the export kinds (adapter).
159    if after_clause_keyword(line_prefix, "exports") {
160        return export_kind_candidates();
161    }
162    // 4f. `provides <cursor>` — the in-scope capabilities to implement.
163    if after_clause_keyword(line_prefix, "provides") {
164        return in_scope_capabilities(doc_text, files);
165    }
166    // 4g. `where <cursor>` — the closed refinement-predicate vocabulary.
167    // Shared by a type declaration's `type X = Base where <cursor>` and
168    // (#472) a match arm's `_ where <cursor>`; both reuse the same closed
169    // predicate catalogue, so one branch serves both surfaces. Excludes a
170    // `for all x: T, … where <cursor>` binder's clause, which takes an
171    // arbitrary `Bool` expression, not the predicate catalogue — that case
172    // falls through to expression-position candidates below.
173    if after_clause_keyword(line_prefix, "where") && !is_for_all_where(line_prefix) {
174        return predicate_name_candidates();
175    }
176    // 5. Type position (`: T`, `-> T`, `[ … ]` type args) — built-ins, the
177    //    `bynk`-surface transparent types, and project type declarations.
178    if is_type_position(line_prefix) {
179        return type_candidates(doc_text, files);
180    }
181    // 6. Keyword position (a bare word at a declaration/statement start) — the
182    //    reserved keywords plus declaration snippets.
183    if is_keyword_position(line_prefix) {
184        return keyword_and_snippet_candidates();
185    }
186    // 7. Expression position (after `=`/`(`/`,`/`=>`/a binary operator) — a value
187    //    starts here: the constructor keywords + in-scope type names. In-scope
188    //    locals/params (and, from slice 3, free functions) are appended
189    //    handler-side, where the analysis cache lives (ADR 0093 D3).
190    if is_expression_position(line_prefix) {
191        return expression_candidates(doc_text, files);
192    }
193    Vec::new()
194}
195
196// -- Cursor-context detection (line-prefix scanning) --
197
198/// `consumes U { … ` with the brace still open at the cursor → `Some(U)`.
199fn consumes_brace_unit(line: &str) -> Option<String> {
200    let idx = line.rfind("consumes")?;
201    let after = &line[idx + "consumes".len()..];
202    let open = after.find('{')?;
203    // The brace must still be open up to the cursor (no closing brace after it).
204    if after[open + 1..].contains('}') {
205        return None;
206    }
207    let unit = after[..open].trim();
208    if unit.is_empty() || !is_qualified_name(unit) {
209        return None;
210    }
211    Some(unit.to_string())
212}
213
214/// `consumes <partial>` with no brace or `as` yet → completing the target name.
215fn is_consumes_target(line: &str) -> bool {
216    let Some(idx) = line.rfind("consumes") else {
217        return false;
218    };
219    // `consumes` must be a standalone keyword (preceded by start/whitespace).
220    if !line[..idx]
221        .chars()
222        .last()
223        .map(|c| c.is_whitespace())
224        .unwrap_or(true)
225    {
226        return false;
227    }
228    let after = &line[idx + "consumes".len()..];
229    // Need at least one separating space, and no `{`, `}`, or `as` yet.
230    after.starts_with(char::is_whitespace)
231        && !after.contains('{')
232        && !after.contains('}')
233        && !after.split_whitespace().any(|w| w == "as")
234}
235
236/// The cursor is inside a `given` list (after `given`, before the `{` body).
237fn is_given_position(line: &str) -> bool {
238    let Some(idx) = line.rfind("given") else {
239        return false;
240    };
241    if !line[..idx]
242        .chars()
243        .last()
244        .map(|c| c.is_whitespace())
245        .unwrap_or(true)
246    {
247        return false;
248    }
249    let after = &line[idx + "given".len()..];
250    if !after.starts_with(char::is_whitespace) {
251        return false;
252    }
253    // Still in the given list while only capability names, dots, commas and
254    // whitespace follow — a `{` opens the handler body.
255    after
256        .chars()
257        .all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | ',' | ' ' | '\t'))
258}
259
260fn is_qualified_name(s: &str) -> bool {
261    !s.is_empty()
262        && s.split('.').all(|seg| {
263            !seg.is_empty()
264                && seg.chars().all(|c| c.is_alphanumeric() || c == '_')
265                && !seg.chars().next().unwrap().is_ascii_digit()
266        })
267}
268
269/// The cursor sits in a type position: a return type (`-> T`), a type
270/// annotation/field type (`: T`), or inside a `[ … ]` type-argument list. The
271/// partial type name being typed is stripped before inspecting the preceding
272/// token, so `: Optio` and `-> Eff` both qualify.
273///
274/// Conservative by construction: a list literal `[1, 2` is excluded (its `[` is
275/// not preceded by a type constructor). The one accepted false positive is a
276/// record *construction* value (`Order { id: <cursor>`), lexically identical to
277/// a record field-type declaration — offering type names there is mild noise.
278fn is_type_position(line: &str) -> bool {
279    let head = line
280        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
281        .trim_end();
282    head.ends_with("->") || (head.ends_with(':') && !head.ends_with("::")) || in_type_arg_list(head)
283}
284
285/// `head` ends inside an unclosed `[ … ` whose opening bracket immediately
286/// follows an identifier (a type constructor, e.g. `Option[`, `Result[Int, `) —
287/// as opposed to a bare list-literal `[`.
288fn in_type_arg_list(head: &str) -> bool {
289    let chars: Vec<char> = head.chars().collect();
290    let mut depth = 0i32;
291    let mut opener_after_ident = false;
292    for (i, &c) in chars.iter().enumerate() {
293        match c {
294            '[' => {
295                depth += 1;
296                if depth == 1 {
297                    opener_after_ident =
298                        i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_');
299                }
300            }
301            ']' => depth -= 1,
302            _ => {}
303        }
304    }
305    depth > 0 && opener_after_ident
306}
307
308/// A bare word at a declaration/statement start: the line up to the cursor is
309/// only leading whitespace plus an optional partial identifier (no operators,
310/// colons, or brackets). Fires on an empty line too. Disjoint from
311/// `is_type_position` (an internal helper), whose triggers (`:`/`->`/`[`) make
312/// this false.
313pub fn is_keyword_position(line: &str) -> bool {
314    line.trim().chars().all(|c| c.is_alphanumeric() || c == '_')
315}
316
317/// The cursor sits where a **value** expression is expected — after `=`/`(`/`,`,
318/// a `=>` lambda arrow, or a binary operator — so in-scope locals are offered
319/// (v0.31, ADR 0064). Conservative: covers the common positions, excludes the
320/// type arrow `->`. (The handler also offers locals at keyword position.)
321pub fn is_expression_position(line: &str) -> bool {
322    let head = line
323        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
324        .trim_end();
325    if head.ends_with("->") {
326        return false; // a return/param type, not a value
327    }
328    if head.ends_with("=>") {
329        return true; // a lambda body
330    }
331    matches!(
332        head.chars().last(),
333        Some('=' | '(' | ',' | '[' | '+' | '-' | '*' | '/' | '<' | '>' | '&' | '|')
334    )
335}
336
337/// `UpperIdent.<partial>` at the cursor → `Some("UpperIdent")` — a name
338/// receiver whose members are statically enumerable (a sum/refined/opaque
339/// type or a capability). Conservative: the receiver is a **single**
340/// uppercase-initial identifier, not itself a `.`-qualified segment (so
341/// `bynk.cloudflare.` and `a.B.` are excluded) and not a number (so the
342/// decimal `1.` is excluded). A lowercase `x.` is a *value* receiver — deferred
343/// to slice 3 — and yields `None`.
344fn member_receiver(line: &str) -> Option<String> {
345    // Drop the partial member name being typed, then require a trailing dot.
346    let head = line
347        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
348        .strip_suffix('.')?;
349    // The receiver is the identifier immediately before that dot. Advance past
350    // the matched char by its UTF-8 length: a multi-byte non-identifier char
351    // (`"`, `€`, `—`, …) would make `i + 1` land mid-codepoint and panic.
352    let start = head
353        .char_indices()
354        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
355        .map_or(0, |(i, c)| i + c.len_utf8());
356    let recv = &head[start..];
357    let first = recv.chars().next()?;
358    if !first.is_ascii_uppercase() {
359        return None;
360    }
361    // Reject a `.`-qualified receiver (`a.B.`): the char before it is a dot.
362    if head[..start].ends_with('.') {
363        return None;
364    }
365    Some(recv.to_string())
366}
367
368/// v0.124 (slice 3): the cursor is at a field-*name* position of a record
369/// construction — inside an unclosed `{` opened immediately after an
370/// uppercase-initial type name (`Order { <cursor>` / `Order { id: 1, <cursor>`),
371/// with the current field segment not yet past its `:` (a field *type*
372/// position, left to [`is_type_position`]). Returns the record type name.
373fn record_construction_receiver(line: &str) -> Option<String> {
374    // The innermost `{` still open at the cursor.
375    let bytes = line.as_bytes();
376    let mut depth = 0i32;
377    let mut open = None;
378    for i in (0..bytes.len()).rev() {
379        match bytes[i] {
380            b'}' => depth += 1,
381            b'{' => {
382                if depth == 0 {
383                    open = Some(i);
384                    break;
385                }
386                depth -= 1;
387            }
388            _ => {}
389        }
390    }
391    let open = open?;
392    // Only at a name position: the current field (since the last comma) has no
393    // `:` yet — else the cursor is in that field's type.
394    let current = line[open + 1..].rsplit(',').next().unwrap_or("");
395    if current.contains(':') {
396        return None;
397    }
398    // The receiver is the uppercase-initial identifier immediately before `{`.
399    let head = line[..open].trim_end();
400    let start = head
401        .char_indices()
402        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
403        .map_or(0, |(i, c)| i + c.len_utf8());
404    let recv = &head[start..];
405    if recv.chars().next()?.is_ascii_uppercase() {
406        Some(recv.to_string())
407    } else {
408        None
409    }
410}
411
412/// v0.131: the CORS policy fields, offered at a field-name position inside a
413/// `cors { }` block. `Allow-Methods` is deliberately absent (derived from the
414/// routes), so the closed set is these four.
415pub const CORS_FIELDS: &[(&str, &str)] = &[
416    (
417        "origins",
418        "the allowed origins — an exact allowlist, or `[\"*\"]`",
419    ),
420    (
421        "headers",
422        "the `Access-Control-Allow-Headers` a preflight advertises",
423    ),
424    (
425        "credentials",
426        "whether credentialed requests are allowed (`true`/`false`)",
427    ),
428    (
429        "maxAge",
430        "how long a browser may cache the preflight (a `Duration`)",
431    ),
432];
433
434/// v0.141 (ADR 0164): the security-headers policy fields, offered at a field-name
435/// position inside a `security { }` block. The closed set is these two.
436pub const SECURITY_FIELDS: &[(&str, &str)] = &[
437    (
438        "nosniff",
439        "stamp `X-Content-Type-Options: nosniff` (`true`/`false`, default `true`)",
440    ),
441    (
442        "hsts",
443        "opt in to `Strict-Transport-Security` — the `max-age` as a `Duration`",
444    ),
445];
446
447/// v0.142 (ADR 0165): the request-limits policy fields, offered at a field-name
448/// position inside a `limits { }` block. The closed set is this one.
449pub const LIMITS_FIELDS: &[(&str, &str)] = &[(
450    "maxBody",
451    "the maximum request body size in bytes (a positive `Int`)",
452)];
453
454/// v0.140 (ADR 0163): the `@cache` handler-annotation arguments, offered at an
455/// argument-name position inside `@cache( … )`. `maxAge` is required (the freshness
456/// window); `scope` is optional (`public`/`private`, default `private`). The
457/// conditional `ETag`/`304` half is automatic and has no surface.
458pub const CACHE_ARGS: &[(&str, &str)] = &[
459    (
460        "maxAge",
461        "the freshness window — a `Duration` (e.g. `5.minutes`) lowered to `Cache-Control: max-age`",
462    ),
463    (
464        "scope",
465        "`public` or `private` (default `private` — a shared cache stores only on `public`)",
466    ),
467];
468
469/// v0.142 (ADR 0165): the `@limit` handler-annotation arguments, offered at an
470/// argument-name position inside `@limit( … )`. Its one arg is `maxBody`, the
471/// request-body byte ceiling above which a `413` is synthesised before the body
472/// is read.
473pub const LIMIT_ARGS: &[(&str, &str)] = &[(
474    "maxBody",
475    "the maximum request body size in bytes (a positive `Int`) — a `413` is synthesised past it",
476)];
477
478/// The byte index of the innermost `{` still open at `offset` (a naive scan that,
479/// like [`record_construction_receiver`], does not skip braces inside strings or
480/// comments — acceptable for a completion heuristic).
481fn innermost_open_brace(text: &str, offset: usize) -> Option<usize> {
482    let bytes = text.as_bytes();
483    let end = offset.min(bytes.len());
484    let mut depth = 0i32;
485    for i in (0..end).rev() {
486        match bytes[i] {
487            b'}' => depth += 1,
488            b'{' => {
489                if depth == 0 {
490                    return Some(i);
491                }
492                depth -= 1;
493            }
494            _ => {}
495        }
496    }
497    None
498}
499
500/// The bare word immediately before byte index `open` (skipping trailing
501/// whitespace), e.g. the `cors` before a `cors {`.
502fn word_before_brace(text: &str, open: usize) -> &str {
503    let head = text[..open].trim_end();
504    let start = head
505        .char_indices()
506        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
507        .map_or(0, |(i, c)| i + c.len_utf8());
508    &head[start..]
509}
510
511/// v0.131: the cursor is at a field-*name* position inside a `cors { … }` block —
512/// the innermost open brace is opened by `cors`, and the current field segment
513/// (since the last `,` or newline) has no `:` yet (a value position).
514pub fn in_cors_field_position(text: &str, offset: usize) -> bool {
515    let Some(open) = innermost_open_brace(text, offset) else {
516        return false;
517    };
518    if word_before_brace(text, open) != "cors" {
519        return false;
520    }
521    // `offset` arrives on a char boundary from the position converter, but
522    // slice defensively — a mid-codepoint offset must degrade, not panic.
523    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
524        return false;
525    };
526    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
527    !current.contains(':')
528}
529
530/// v0.141: the cursor is at a field-*name* position inside a `security { … }`
531/// block — the innermost open brace is opened by `security`, and the current
532/// field segment has no `:` yet. Mirrors [`in_cors_field_position`].
533pub fn in_security_field_position(text: &str, offset: usize) -> bool {
534    let Some(open) = innermost_open_brace(text, offset) else {
535        return false;
536    };
537    if word_before_brace(text, open) != "security" {
538        return false;
539    }
540    // `offset` arrives on a char boundary from the position converter, but
541    // slice defensively — a mid-codepoint offset must degrade, not panic.
542    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
543        return false;
544    };
545    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
546    !current.contains(':')
547}
548
549/// v0.142 (ADR 0165): the cursor is at a field-*name* position inside a
550/// `limits { … }` block — the innermost open brace is opened by `limits`, and the
551/// current field segment has no `:` yet. Mirrors [`in_security_field_position`].
552pub fn in_limits_field_position(text: &str, offset: usize) -> bool {
553    let Some(open) = innermost_open_brace(text, offset) else {
554        return false;
555    };
556    if word_before_brace(text, open) != "limits" {
557        return false;
558    }
559    // `offset` arrives on a char boundary from the position converter, but
560    // slice defensively — a mid-codepoint offset must degrade, not panic.
561    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
562        return false;
563    };
564    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
565    !current.contains(':')
566}
567
568/// v0.131: the cursor is at a service-body item start — a bare word inside a
569/// `service … {` block (not a nested block) — where `cors` may begin, alongside
570/// the `on` handler kinds. Gated on `is_keyword_position` so it only fires at a
571/// fresh item start, and on the enclosing brace's header line naming `service`.
572pub fn in_service_body_item_position(text: &str, offset: usize, line_prefix: &str) -> bool {
573    if !is_keyword_position(line_prefix) {
574        return false;
575    }
576    let Some(open) = innermost_open_brace(text, offset) else {
577        return false;
578    };
579    let header_start = text[..open].rfind('\n').map_or(0, |i| i + 1);
580    text[header_start..open].contains("service ")
581}
582
583/// The byte index of the innermost `(` still open at `offset` — the paren analogue
584/// of [`innermost_open_brace`], for `@cache( … )` argument-position detection.
585fn innermost_open_paren(text: &str, offset: usize) -> Option<usize> {
586    let bytes = text.as_bytes();
587    let end = offset.min(bytes.len());
588    let mut depth = 0i32;
589    for i in (0..end).rev() {
590        match bytes[i] {
591            b')' => depth += 1,
592            b'(' => {
593                if depth == 0 {
594                    return Some(i);
595                }
596                depth -= 1;
597            }
598            _ => {}
599        }
600    }
601    None
602}
603
604/// v0.140 (ADR 0163): the cursor is at an argument-*name* position inside a
605/// `@cache( … )` — the innermost open paren is opened by the `cache` annotation
606/// (`@cache`, not a bare `cache(` call), and the current argument segment (since
607/// the last `,`) has no `:` yet (a name position, not a value one).
608pub fn in_cache_arg_position(text: &str, offset: usize) -> bool {
609    let Some(open) = innermost_open_paren(text, offset) else {
610        return false;
611    };
612    if word_before_brace(text, open) != "cache" {
613        return false;
614    }
615    // Distinguish the `@cache` annotation from any ordinary `cache(...)` call: the
616    // word must be immediately preceded by `@`.
617    let head = text[..open].trim_end();
618    let before_cache = head[..head.len() - "cache".len()].trim_end();
619    if !before_cache.ends_with('@') {
620        return false;
621    }
622    // `offset` arrives on a char boundary from the position converter, but
623    // slice defensively — a mid-codepoint offset must degrade, not panic.
624    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
625        return false;
626    };
627    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
628    !current.contains(':')
629}
630
631/// v0.142 (ADR 0165): the cursor is at an argument-*name* position inside a
632/// `@limit( … )` — the innermost open paren is opened by the `limit` annotation
633/// (`@limit`, not a bare `limit(` call), and the current argument segment has no
634/// `:` yet. Mirrors [`in_cache_arg_position`].
635pub fn in_limit_arg_position(text: &str, offset: usize) -> bool {
636    let Some(open) = innermost_open_paren(text, offset) else {
637        return false;
638    };
639    if word_before_brace(text, open) != "limit" {
640        return false;
641    }
642    // Distinguish the `@limit` annotation from any ordinary `limit(...)` call: the
643    // word must be immediately preceded by `@`.
644    let head = text[..open].trim_end();
645    let before_limit = head[..head.len() - "limit".len()].trim_end();
646    if !before_limit.ends_with('@') {
647        return false;
648    }
649    // `offset` arrives on a char boundary from the position converter, but
650    // slice defensively — a mid-codepoint offset must degrade, not panic.
651    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
652        return false;
653    };
654    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
655    !current.contains(':')
656}
657
658/// v0.124 (slice 3): the cursor is completing the argument to a leading clause
659/// keyword — `from`/`on`/`by`/`exports`/`provides <cursor>` — with only a
660/// partial identifier typed after the keyword. `kw` must be a standalone word
661/// (line start or whitespace before it), so a field named `from` or the `on`
662/// inside `session` does not trigger it.
663fn after_clause_keyword(line: &str, kw: &str) -> bool {
664    let Some(idx) = line.rfind(kw) else {
665        return false;
666    };
667    if !line[..idx]
668        .chars()
669        .last()
670        .map(char::is_whitespace)
671        .unwrap_or(true)
672    {
673        return false;
674    }
675    let after = &line[idx + kw.len()..];
676    after.starts_with(char::is_whitespace)
677        && after
678            .trim_start()
679            .chars()
680            .all(|c| c.is_alphanumeric() || c == '_')
681}
682
683/// #472 (finding 2, PR #827 review): `for all x: T, y: U where <cursor>` — a
684/// generative-test binder's clause. Unlike a type declaration's or a refined
685/// pattern's `where`, this one takes an arbitrary `Bool` expression over the
686/// bound names, not the closed predicate catalogue; `after_clause_keyword`
687/// only matches the keyword text, so this excludes that one `where` position
688/// from the predicate-vocabulary branch. `line`'s last `where` is checked
689/// against the text immediately before it, ignoring the binding list, since
690/// `for`/`all` are contextual (not lexer) keywords only meaningful in this
691/// exact `for all` sequence.
692fn is_for_all_where(line: &str) -> bool {
693    let Some(idx) = line.rfind("where") else {
694        return false;
695    };
696    let head = line[..idx].trim_start();
697    let Some(after_for) = head.strip_prefix("for") else {
698        return false;
699    };
700    // `for` must be a standalone word (a whitespace boundary), not a prefix
701    // of a longer identifier (`format(...)`).
702    if !after_for.starts_with(char::is_whitespace) {
703        return false;
704    }
705    let Some(after_all) = after_for.trim_start().strip_prefix("all") else {
706        return false;
707    };
708    // Likewise `all` — `allocate` is not the `for all` keyword.
709    after_all.is_empty() || after_all.starts_with(char::is_whitespace)
710}
711
712/// v0.124 (slice 3): the cursor sits in a contract-clause predicate —
713/// `requires <name>: <cursor>` or `ensures <name>: <cursor>` — where the
714/// enclosing function's parameters (and, for an `ensures`, `result`) are in
715/// scope. Returns `Some(is_ensures)`; the parameters themselves are resolved
716/// handler-side from the enclosing `fn` (needs the cursor offset).
717pub fn contract_clause_kind(line: &str) -> Option<bool> {
718    let colon = line.rfind(':')?;
719    let clause = line[..colon].trim();
720    for (kw, is_ensures) in [("requires", false), ("ensures", true)] {
721        if let Some(rest) = clause.strip_prefix(kw) {
722            let rest = rest.trim();
723            if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '_') {
724                return Some(is_ensures);
725            }
726        }
727    }
728    None
729}
730
731/// The record fields of a project (or embedded-surface) type named `name`, as
732/// field-name completions — the construction-position half of what
733/// [`value_member_candidates`] offers on a value receiver.
734///
735/// Finding #62: stops at the first unit declaring `name` rather than unioning
736/// fields across every matching unit — `for_each_unit` can otherwise yield
737/// the live buffer *and* that same file's stale on-disk copy, and a name
738/// removed from the buffer's declaration must not resurface from the disk
739/// copy's fields.
740fn record_field_names(
741    name: &str,
742    doc_text: &str,
743    files: Option<&HashMap<PathBuf, String>>,
744) -> Vec<Completion> {
745    let mut out: Vec<Completion> = Vec::new();
746    let mut found = false;
747    for_each_unit(doc_text, files, |unit| {
748        if found {
749            return;
750        }
751        let items = match unit {
752            SourceUnit::Commons(c) => &c.items,
753            SourceUnit::Context(c) => &c.items,
754            SourceUnit::Adapter(a) => &a.items,
755            _ => return,
756        };
757        for item in items {
758            if let CommonsItem::Type(t) = item
759                && t.name.name == name
760                && let TypeBody::Record(r) = &t.body
761            {
762                found = true;
763                for f in &r.fields {
764                    out.push(Completion::item(
765                        f.name.name.clone(),
766                        CompletionKind::Field,
767                        Some(format!("field of `{name}`")),
768                    ));
769                }
770                return;
771            }
772        }
773    });
774    out
775}
776
777/// v0.124 (slice 3): the variants of a project (or embedded-surface) sum type
778/// named `name`, as pattern completions — the `is`/`match` candidate set once
779/// the scrutinee's type is known (resolved handler-side from `expr_types`).
780/// `pub` so the completion handler can offer them at an `is` position.
781///
782/// Finding #62: stops at the first unit declaring `name`, for the same
783/// buffer-vs-stale-disk-copy reason as `record_field_names` above.
784pub fn sum_type_variants(
785    name: &str,
786    doc_text: &str,
787    files: Option<&HashMap<PathBuf, String>>,
788) -> Vec<Completion> {
789    let mut out: Vec<Completion> = Vec::new();
790    let mut found = false;
791    for_each_unit(doc_text, files, |unit| {
792        if found {
793            return;
794        }
795        let items = match unit {
796            SourceUnit::Commons(c) => &c.items,
797            SourceUnit::Context(c) => &c.items,
798            SourceUnit::Adapter(a) => &a.items,
799            _ => return,
800        };
801        for item in items {
802            if let CommonsItem::Type(t) = item
803                && t.name.name == name
804                && let TypeBody::Sum(s) = &t.body
805            {
806                found = true;
807                for v in &s.variants {
808                    out.push(Completion::item(
809                        v.name.name.clone(),
810                        CompletionKind::Variant,
811                        Some(format!("variant of `{name}`")),
812                    ));
813                }
814                return;
815            }
816        }
817    });
818    out
819}
820
821/// v0.145 (ADR 0169, base gap for #565): the variants offerable for a scrutinee
822/// `Ty` at a pattern position. A user-declared sum's variants come from source
823/// (`sum_type_variants`); the built-in `Result`/`Option` variants do not (they
824/// are not declared types, so `sum_type_variants` can't see them) and are
825/// intrinsic here. This is why match-arm / `is` completion now fires for a
826/// `Result`/`Option` scrutinee, not only a user sum.
827pub fn variants_for_ty(
828    ty: TyId,
829    tys: &Types,
830    doc_text: &str,
831    files: Option<&HashMap<PathBuf, String>>,
832) -> Vec<Completion> {
833    match &*tys.get(ty) {
834        Ty::Named { name, .. } => sum_type_variants(name, doc_text, files),
835        Ty::Result(..) => built_in_variants(&["Ok", "Err"], "Result"),
836        Ty::Option(..) => built_in_variants(&["Some", "None"], "Option"),
837        _ => Vec::new(),
838    }
839}
840
841/// v0.145 (ADR 0169, nested-variant completion for #565): the variants offerable
842/// inside `OuterVariant(‸` within a match arm — the payload field type's
843/// variants. Resolves the single-field payload type of `outer_variant` on the
844/// scrutinee `ty` (the same shape as `bynk-emit`'s `payload_field_ty`):
845/// `Result`/`Option`/`HttpResult` generic args come straight off the `Ty`, and a
846/// user-declared sum's field type is walked from source. `Ok`/`Err` inside
847/// `Some(‸)` on an `Option[Result[…]]` is the headline case.
848pub fn nested_variant_completions(
849    ty: TyId,
850    tys: &Types,
851    outer_variant: &str,
852    doc_text: &str,
853    files: Option<&HashMap<PathBuf, String>>,
854) -> Vec<Completion> {
855    match &*tys.get(ty) {
856        Ty::Result(t, e) => match outer_variant {
857            "Ok" => variants_for_ty(*t, tys, doc_text, files),
858            "Err" => variants_for_ty(*e, tys, doc_text, files),
859            _ => Vec::new(),
860        },
861        Ty::HttpResult(t) if outer_variant == "Ok" => variants_for_ty(*t, tys, doc_text, files),
862        Ty::Option(t) if outer_variant == "Some" => variants_for_ty(*t, tys, doc_text, files),
863        Ty::Named {
864            kind: NamedKind::Sum,
865            name,
866            ..
867        } => payload_type_ref_variants(name, outer_variant, doc_text, files),
868        _ => Vec::new(),
869    }
870}
871
872/// The built-in variant names of `Result`/`Option` as `Variant` completions.
873fn built_in_variants(names: &[&str], of: &str) -> Vec<Completion> {
874    names
875        .iter()
876        .map(|v| {
877            Completion::item(
878                (*v).to_string(),
879                CompletionKind::Variant,
880                Some(format!("variant of `{of}`")),
881            )
882        })
883        .collect()
884}
885
886/// The variants of the payload field type of `variant` on the user-declared sum
887/// `sum_name`, walked from source (the LSP holds no resolved type map, so it
888/// reads the field's `TypeRef` off the parsed decl — the source-side analogue of
889/// `payload_field_ty`'s `commons.types` lookup).
890fn payload_type_ref_variants(
891    sum_name: &str,
892    variant: &str,
893    doc_text: &str,
894    files: Option<&HashMap<PathBuf, String>>,
895) -> Vec<Completion> {
896    let mut field_ty: Option<TypeRef> = None;
897    for_each_unit(doc_text, files, |unit| {
898        let items = match unit {
899            SourceUnit::Commons(c) => &c.items,
900            SourceUnit::Context(c) => &c.items,
901            SourceUnit::Adapter(a) => &a.items,
902            _ => return,
903        };
904        for item in items {
905            if let CommonsItem::Type(t) = item
906                && t.name.name == sum_name
907                && let TypeBody::Sum(s) = &t.body
908                && let Some(v) = s.variants.iter().find(|v| v.name.name == variant)
909                && let Some(f) = v.payload.first()
910            {
911                field_ty = Some(f.type_ref.clone());
912            }
913        }
914    });
915    match field_ty {
916        Some(tr) => variants_for_type_ref(&tr, doc_text, files),
917        None => Vec::new(),
918    }
919}
920
921/// `variants_for_ty` over an unresolved `TypeRef` (a user-sum payload field). A
922/// named type's variants come from source; `Result`/`Option` are intrinsic.
923fn variants_for_type_ref(
924    tr: &TypeRef,
925    doc_text: &str,
926    files: Option<&HashMap<PathBuf, String>>,
927) -> Vec<Completion> {
928    match tr {
929        TypeRef::Named(id) => sum_type_variants(&id.name, doc_text, files),
930        TypeRef::Result(..) => built_in_variants(&["Ok", "Err"], "Result"),
931        TypeRef::Option(..) => built_in_variants(&["Some", "None"], "Option"),
932        _ => Vec::new(),
933    }
934}
935
936/// The service protocols offerable after `from`.
937fn protocol_candidates() -> Vec<Completion> {
938    ["http", "cron", "queue", "websocket"]
939        .into_iter()
940        .map(|p| Completion::item(p, CompletionKind::Keyword, Some("service protocol".into())))
941        .collect()
942}
943
944/// The handler kinds offerable after `on`.
945fn handler_kind_candidates() -> Vec<Completion> {
946    [
947        "call", "GET", "POST", "PUT", "PATCH", "DELETE", "schedule", "message", "open", "close",
948    ]
949    .into_iter()
950    .map(|k| Completion::item(k, CompletionKind::Keyword, Some("handler kind".into())))
951    .collect()
952}
953
954/// The export kinds offerable after `exports` (adapter).
955fn export_kind_candidates() -> Vec<Completion> {
956    ["capability", "transparent", "opaque"]
957        .into_iter()
958        .map(|k| Completion::item(k, CompletionKind::Keyword, Some("export kind".into())))
959        .collect()
960}
961
962/// The closed refinement-predicate vocabulary offerable after `where` —
963/// shared by a type declaration's refinement and (#472) a match arm's
964/// `_ where <predicate>`. Mirrors `PredKind::name()`.
965fn predicate_name_candidates() -> Vec<Completion> {
966    [
967        "Matches",
968        "InRange",
969        "MinLength",
970        "MaxLength",
971        "Length",
972        "NonNegative",
973        "Positive",
974        "NonEmpty",
975    ]
976    .into_iter()
977    .map(|k| {
978        Completion::item(
979            k,
980            CompletionKind::Keyword,
981            Some("refinement predicate".into()),
982        )
983    })
984    .collect()
985}
986
987/// The project's `actor` names, offerable after `by`.
988fn actor_candidates(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<Completion> {
989    let mut out: Vec<Completion> = Vec::new();
990    let mut seen: BTreeSet<String> = BTreeSet::new();
991    for_each_unit(doc_text, files, |unit| {
992        let items = match unit {
993            SourceUnit::Commons(c) => &c.items,
994            SourceUnit::Context(c) => &c.items,
995            SourceUnit::Adapter(a) => &a.items,
996            _ => return,
997        };
998        for item in items {
999            if let CommonsItem::Actor(a) = item
1000                && seen.insert(a.name.name.clone())
1001            {
1002                out.push(Completion::item(
1003                    a.name.name.clone(),
1004                    CompletionKind::Type,
1005                    Some("actor".into()),
1006                ));
1007            }
1008        }
1009    });
1010    out
1011}
1012
1013/// Built-in type statics — real language statics that are not user-declared, so
1014/// they come from this small table rather than the project parse. Covers the
1015/// numeric parse statics and the JSON codec (v0.22, ADRs 0048/0049), the
1016/// collection `empty` constructors (v0.20b), and `Effect.pure` (v0.5). The full
1017/// real set per ADR 0093 D2 — kept complete and drift-tested
1018/// (`builtin_statics_are_reachable`).
1019pub const BUILTIN_STATICS: &[(&str, &[(&str, &str)])] = &[
1020    ("Int", &[("parse", "parse(s: String) -> Option[Int]")]),
1021    ("Float", &[("parse", "parse(s: String) -> Option[Float]")]),
1022    (
1023        "Json",
1024        &[
1025            ("encode", "encode(value) -> String"),
1026            ("decode", "decode[T](s: String) -> Result[T, JsonError]"),
1027        ],
1028    ),
1029    ("List", &[("empty", "empty() -> List[T]")]),
1030    ("Map", &[("empty", "empty() -> Map[K, V]")]),
1031    ("Effect", &[("pure", "pure(value) -> Effect[T]")]),
1032    (
1033        "Bytes",
1034        &[
1035            ("fromUtf8", "fromUtf8(s: String) -> Bytes"),
1036            ("fromBase64", "fromBase64(s: String) -> Option[Bytes]"),
1037            ("empty", "empty() -> Bytes"),
1038        ],
1039    ),
1040];
1041
1042/// Variants of a built-in sum type (`HttpResult`/`QueueResult`), sourced from
1043/// the AST variant registries so a new variant surfaces in completion for free
1044/// (ADR 0093 D2/G3). Empty for any other receiver.
1045fn builtin_sum_variants(receiver: &str) -> Vec<(String, String)> {
1046    match receiver {
1047        "HttpResult" => bynk_syntax::ast::HTTP_VARIANTS
1048            .iter()
1049            .map(|v| {
1050                (
1051                    v.name.to_string(),
1052                    format!("variant of `HttpResult` ({})", v.status),
1053                )
1054            })
1055            .collect(),
1056        "QueueResult" => bynk_syntax::ast::QUEUE_VARIANTS
1057            .iter()
1058            .map(|v| (v.name.to_string(), "variant of `QueueResult`".to_string()))
1059            .collect(),
1060        _ => Vec::new(),
1061    }
1062}
1063
1064/// Members of a name receiver: built-in type statics, then built-in sum-type
1065/// variants, then — from the project and embedded-surface parse — project sum
1066/// variants, refined/opaque `of`/`unsafe`, or capability operations. Yields `[]`
1067/// when the receiver resolves to none of these (e.g. a plain `type X = Int`
1068/// alias or a record).
1069fn member_candidates(
1070    receiver: &str,
1071    doc_text: &str,
1072    files: Option<&HashMap<PathBuf, String>>,
1073) -> Vec<Completion> {
1074    if let Some((_, statics)) = BUILTIN_STATICS.iter().find(|(name, _)| *name == receiver) {
1075        return statics
1076            .iter()
1077            .map(|(label, sig)| {
1078                Completion::item(*label, CompletionKind::Member, Some(sig.to_string()))
1079            })
1080            .collect();
1081    }
1082    let mut out: Vec<Completion> = Vec::new();
1083    let mut seen: BTreeSet<String> = BTreeSet::new();
1084    // Built-in sum types (`HttpResult`/`QueueResult`) — variants from the AST
1085    // registry, on the same name-receiver path as project sums (ADR 0093 G3).
1086    for (label, detail) in builtin_sum_variants(receiver) {
1087        if seen.insert(label.clone()) {
1088            out.push(Completion::item(
1089                label,
1090                CompletionKind::Variant,
1091                Some(detail),
1092            ));
1093        }
1094    }
1095    for_each_unit(doc_text, files, |unit| {
1096        let items = match unit {
1097            SourceUnit::Commons(c) => &c.items,
1098            SourceUnit::Context(c) => &c.items,
1099            SourceUnit::Adapter(a) => &a.items,
1100            _ => return,
1101        };
1102        for item in items {
1103            match item {
1104                CommonsItem::Type(t) if t.name.name == receiver => match &t.body {
1105                    bynk_syntax::ast::TypeBody::Sum(s) => {
1106                        for v in &s.variants {
1107                            if seen.insert(v.name.name.clone()) {
1108                                out.push(Completion::item(
1109                                    v.name.name.clone(),
1110                                    CompletionKind::Variant,
1111                                    Some(format!("variant of `{receiver}`")),
1112                                ));
1113                            }
1114                        }
1115                    }
1116                    bynk_syntax::ast::TypeBody::Refined { .. }
1117                    | bynk_syntax::ast::TypeBody::Opaque { .. } => {
1118                        for (label, sig) in [
1119                            (
1120                                "of",
1121                                format!("of(value) -> Result[{receiver}, ValidationError]"),
1122                            ),
1123                            ("unsafe", format!("unsafe(value) -> {receiver}")),
1124                        ] {
1125                            if seen.insert(label.to_string()) {
1126                                out.push(Completion::item(
1127                                    label,
1128                                    CompletionKind::Member,
1129                                    Some(sig),
1130                                ));
1131                            }
1132                        }
1133                    }
1134                    // A plain alias (`type X = Int`) or a record has no
1135                    // name-receiver members — record fields are value-receiver
1136                    // (slice 3).
1137                    _ => {}
1138                },
1139                CommonsItem::Capability(c) if c.name.name == receiver => {
1140                    for op in &c.ops {
1141                        if seen.insert(op.name.name.clone()) {
1142                            // Typed signature (params + return), the same
1143                            // `type_ref_str` rendering hover/signature help use —
1144                            // not bare param names (slice 5 detail polish).
1145                            let params = op
1146                                .params
1147                                .iter()
1148                                .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1149                                .collect::<Vec<_>>()
1150                                .join(", ");
1151                            // #926: `[T, …]` type parameters on the op itself.
1152                            let type_params = if op.type_params.is_empty() {
1153                                String::new()
1154                            } else {
1155                                let names: Vec<&str> = op
1156                                    .type_params
1157                                    .iter()
1158                                    .map(|tp| tp.name.name.as_str())
1159                                    .collect();
1160                                format!("[{}]", names.join(", "))
1161                            };
1162                            out.push(Completion::item(
1163                                op.name.name.clone(),
1164                                CompletionKind::Member,
1165                                Some(format!(
1166                                    "{}{type_params}({params}) -> {} — operation of `{receiver}`",
1167                                    op.name.name,
1168                                    type_ref_str(&op.return_type)
1169                                )),
1170                            ));
1171                        }
1172                    }
1173                }
1174                _ => {}
1175            }
1176        }
1177    });
1178    out
1179}
1180
1181// -- Positional candidate sources (static registries + project parse) --
1182
1183/// Built-in type names not declared in any parseable source. Base and generic
1184/// types from the language core; collection types from `builtin_names`. Docs
1185/// are drawn from the `keywords` registry where present (one source of truth).
1186const BUILTIN_TYPES: &[&str] = &[
1187    bynk_check::builtin_names::types::INT,
1188    "Bool",
1189    bynk_check::builtin_names::types::FLOAT,
1190    "String",
1191    "Option",
1192    "Result",
1193    "Effect",
1194    bynk_check::builtin_names::types::LIST,
1195    bynk_check::builtin_names::types::MAP,
1196];
1197
1198/// Declaration snippets (`CompletionItemKind::SNIPPET`), as LSP snippet bodies.
1199/// `pub` so `tests/scaffolds_compile.rs` (ADR 0157) can enumerate them.
1200pub const SNIPPETS: &[(&str, &str)] = &[
1201    // -- Units --
1202    ("context", "context ${1:name} {\n\t$0\n}"),
1203    ("commons", "commons ${1:my.lib}\n\n$0"),
1204    (
1205        "adapter",
1206        "adapter ${1:name} {\n\tbinding \"${2:./module}\"\n\t$0\n}",
1207    ),
1208    // -- Unit-header clauses --
1209    ("uses", "uses ${1:module}"),
1210    ("consumes", "consumes ${1:bynk} { ${2:Random} }"),
1211    // -- Types --
1212    (
1213        "type record",
1214        "type ${1:Name} = {\n\t${2:field}: ${3:Int},\n}",
1215    ),
1216    ("type enum", "type ${1:Name} = enum {\n\t${2:Variant},\n}"),
1217    (
1218        "type refined",
1219        "type ${1:Name} = ${2:String} where ${3:MinLength(1)}",
1220    ),
1221    (
1222        "type opaque",
1223        "type ${1:Name} = opaque ${2:Int} where ${3:NonNegative}",
1224    ),
1225    // -- Functions --
1226    (
1227        "fn",
1228        "fn ${1:name}(${2:x}: ${3:Int}) -> ${4:Int} {\n\t$0\n}",
1229    ),
1230    (
1231        "fn contract",
1232        "fn ${1:name}(${2:x}: ${3:Int}) -> ${4:Int}\n\trequires ${5:in_range}: ${6:x >= 0}\n\tensures ${7:non_negative}: ${8:result >= 0}\n{\n\t$0\n}",
1233    ),
1234    // -- Capabilities & providers --
1235    (
1236        "capability",
1237        "capability ${1:Name} {\n\tfn ${2:op}() -> Effect[${3:Unit}]\n}",
1238    ),
1239    (
1240        "provides",
1241        "provides ${1:Cap} = ${2:Impl} {\n\tfn ${3:op}(${4}) -> Effect[${5:()}] {\n\t\tEffect.pure(${6:()})\n\t}\n}",
1242    ),
1243    // -- Actors & agents --
1244    (
1245        "actor",
1246        "actor ${1:Name} { auth = ${2:Bearer(secret = \"AUTH_JWT_SECRET\")}, identity = ${3:UserId} }",
1247    ),
1248    (
1249        "agent",
1250        "agent ${1:Name} {\n\tkey ${2:id}: ${3:String}\n\n\tstore ${4:status}: Cell[${5:Int}] = ${6:0}\n\n\tinvariant ${7:non_negative}: ${8:status >= 0}\n\n\ttransition ${9:monotonic}: ${10:new.status >= old.status}\n\n\ton call ${11:op}(${12}) -> Effect[Result[${13:()}, String]] {\n\t\tOk(${14:()})\n\t}\n}",
1251    ),
1252    // -- Services & handlers --
1253    (
1254        "service",
1255        "service ${1:name} {\n\ton call(${2}) -> Effect[${3:Unit}] {\n\t\t$0\n\t}\n}",
1256    ),
1257    ("on call", "on call(${1}) -> Effect[${2:Unit}] {\n\t$0\n}"),
1258    (
1259        "on http",
1260        "on ${1|GET,POST,PUT,DELETE,PATCH|}(\"${2:/path}\") (${3:body}: ${4:Req}) -> Effect[HttpResult[${5:Res}]] given ${6:Cap} {\n\t$0\n}",
1261    ),
1262    (
1263        "on cron",
1264        "on schedule(\"${1:0 * * * *}\") () -> Effect[Result[(), String]] {\n\t$0\n\tOk(())\n}",
1265    ),
1266    // -- Tests --
1267    (
1268        "suite",
1269        "suite ${1:target}\n\ncase \"${2:it works}\" {\n\tlet ${3:actual} = ${4:0}\n\texpect ${5:actual == 0}\n}",
1270    ),
1271    (
1272        "property",
1273        "property \"${1:invariant holds}\" {\n\tfor all ${2:x}: ${3:Int} {\n\t\texpect ${4:x == x}\n\t}\n}",
1274    ),
1275];
1276
1277/// The value constructors offered at expression position (ADR 0093 D3) — the
1278/// closed set of `Result`/`Option` variant constructors and the boolean
1279/// literals. A value expression can begin with any of these; their docs reuse
1280/// the `keywords` registry (one source of truth).
1281const CONSTRUCTORS: &[&str] = &["Ok", "Err", "Some", "None", "true", "false"];
1282
1283/// Expression-position candidates: the value constructors plus in-scope type
1284/// names (the entry to a static call like `Int.parse` or a record construction
1285/// like `Order { … }`). In-scope values — locals/params, and from slice 3 free
1286/// functions — are appended by the handler, which owns the analysis cache, so
1287/// they are not produced here (ADR 0093 D3).
1288fn expression_candidates(
1289    doc_text: &str,
1290    files: Option<&HashMap<PathBuf, String>>,
1291) -> Vec<Completion> {
1292    let mut out: Vec<Completion> = CONSTRUCTORS
1293        .iter()
1294        .map(|&name| {
1295            Completion::item(
1296                name,
1297                CompletionKind::Constructor,
1298                keyword_doc(name).map(str::to_string),
1299            )
1300        })
1301        .collect();
1302    // Type names are valid here too (static receiver / record construction); the
1303    // `Type.` member context (slice 1) takes over once the user types the dot.
1304    out.extend(type_candidates(doc_text, files));
1305    // In-scope free functions — the current unit's own `fn`s and the combinators
1306    // of every `uses`-imported module (project + stdlib) — ADR 0093 D3 / G5.
1307    out.extend(free_function_candidates(doc_text, files));
1308    out
1309}
1310
1311/// A unit's top-level items and its `uses` clauses, for the kinds that carry
1312/// free functions. Service/other units contribute neither.
1313fn unit_items_and_uses(unit: &SourceUnit) -> (&[CommonsItem], &[UsesDecl]) {
1314    match unit {
1315        SourceUnit::Commons(c) => (&c.items, &c.uses),
1316        SourceUnit::Context(c) => (&c.items, &c.uses),
1317        SourceUnit::Adapter(a) => (&a.items, &a.uses),
1318        _ => (&[], &[]),
1319    }
1320}
1321
1322/// The qualified name of the unit the cursor's document declares, via a recovery
1323/// parse (the header survives a mid-edit body). `None` for a headerless fragment
1324/// that names no unit.
1325fn current_unit_name(doc_text: &str) -> Option<String> {
1326    let tokens = lexer::tokenize(doc_text).ok()?;
1327    let (unit, _errs) = parser::parse_unit_with_recovery(&tokens, doc_text);
1328    Some(unit?.name().joined())
1329}
1330
1331/// Render a free function's signature for the completion detail, the same way
1332/// hover and signature help do (`symbols::type_ref_str`) — one format, never
1333/// divergent. Mirrors signature help: no generic-parameter list.
1334fn free_fn_signature(name: &str, f: &bynk_syntax::ast::FnDecl) -> String {
1335    let params = f
1336        .params
1337        .iter()
1338        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1339        .collect::<Vec<_>>()
1340        .join(", ");
1341    format!("{name}({params}) -> {}", type_ref_str(&f.return_type))
1342}
1343
1344/// Free-function candidates at expression position: the current unit's own
1345/// top-level `fn`s plus the free `fn`s of every `uses`-imported module (project
1346/// commons and the embedded stdlib). Gated on the `uses` set so a combinator is
1347/// offered only where it is actually in scope (ADR 0093 D3 / G5).
1348fn free_function_candidates(
1349    doc_text: &str,
1350    files: Option<&HashMap<PathBuf, String>>,
1351) -> Vec<Completion> {
1352    let Some(current) = current_unit_name(doc_text) else {
1353        return Vec::new();
1354    };
1355    // One parse pass: collect each unit's name, its free `fn`s (name + signature),
1356    // and its `uses` targets.
1357    struct UnitFns {
1358        name: String,
1359        fns: Vec<(String, String)>,
1360        uses: Vec<String>,
1361    }
1362    let mut units: Vec<UnitFns> = Vec::new();
1363    for_each_unit(doc_text, files, |unit| {
1364        let (items, uses) = unit_items_and_uses(unit);
1365        let fns = items
1366            .iter()
1367            .filter_map(|it| match it {
1368                CommonsItem::Fn(f) => match &f.name {
1369                    FnName::Free(id) => Some((id.name.clone(), free_fn_signature(&id.name, f))),
1370                    FnName::Method { .. } => None,
1371                },
1372                _ => None,
1373            })
1374            .collect();
1375        units.push(UnitFns {
1376            name: unit.name().joined(),
1377            fns,
1378            uses: uses.iter().map(|u| u.target.joined()).collect(),
1379        });
1380    });
1381    // The import scope: the `uses` targets of every unit sharing the current name
1382    // (a unit may span files, so union them).
1383    let mut imported: BTreeSet<String> = BTreeSet::new();
1384    for u in &units {
1385        if u.name == current {
1386            imported.extend(u.uses.iter().cloned());
1387        }
1388    }
1389    // Offer the current unit's own fns and the fns of each imported module.
1390    let mut out: Vec<Completion> = Vec::new();
1391    let mut seen: BTreeSet<String> = BTreeSet::new();
1392    for u in &units {
1393        let own = u.name == current;
1394        if !own && !imported.contains(&u.name) {
1395            continue;
1396        }
1397        let origin = if own { "this unit" } else { u.name.as_str() };
1398        for (name, sig) in &u.fns {
1399            if seen.insert(name.clone()) {
1400                out.push(Completion::item(
1401                    name.clone(),
1402                    CompletionKind::Function,
1403                    Some(format!("{sig} — `{origin}`")),
1404                ));
1405            }
1406        }
1407    }
1408    out
1409}
1410
1411/// The one-line doc for a name in the `keywords` registry, if present.
1412/// `pub` so hover's bare-keyword fallback (ADR 0156) can reuse it —
1413/// completion and hover render the same doc, never a parallel copy.
1414pub fn keyword_doc(word: &str) -> Option<&'static str> {
1415    keywords::KEYWORDS
1416        .iter()
1417        .find(|k| k.word == word)
1418        .map(|k| k.meaning)
1419}
1420
1421/// Type-position candidates: built-in types (with registry docs), then every
1422/// `type` declaration found in the project sources and the embedded `bynk`
1423/// surface (so the transparent surface types `Uuid`/`Method`/… come for free).
1424fn type_candidates(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<Completion> {
1425    let mut out: Vec<Completion> = Vec::new();
1426    let mut seen: BTreeSet<String> = BTreeSet::new();
1427    for &name in BUILTIN_TYPES {
1428        if seen.insert(name.to_string()) {
1429            let detail = keyword_doc(name)
1430                .map(str::to_string)
1431                .or_else(|| match name {
1432                    "List" => Some("The built-in list type, `List[T]`.".to_string()),
1433                    "Map" => Some("The built-in map type, `Map[K, V]`.".to_string()),
1434                    _ => Some("built-in type".to_string()),
1435                });
1436            out.push(Completion::item(name, CompletionKind::Type, detail));
1437        }
1438    }
1439    for_each_unit(doc_text, files, |unit| {
1440        let items = match unit {
1441            SourceUnit::Commons(c) => &c.items,
1442            SourceUnit::Context(c) => &c.items,
1443            SourceUnit::Adapter(a) => &a.items,
1444            _ => return,
1445        };
1446        for item in items {
1447            if let CommonsItem::Type(t) = item
1448                && seen.insert(t.name.name.clone())
1449            {
1450                out.push(Completion::item(
1451                    t.name.name.clone(),
1452                    CompletionKind::Type,
1453                    Some("type".to_string()),
1454                ));
1455            }
1456        }
1457    });
1458    out
1459}
1460
1461/// Keyword-position candidates: the lowercase-initial reserved keywords (the
1462/// declaration/statement words — uppercase type/value names like `Int`/`Some`
1463/// belong to type/expression position) with their registry docs, plus the
1464/// declaration snippets.
1465fn keyword_and_snippet_candidates() -> Vec<Completion> {
1466    let mut out: Vec<Completion> = keywords::KEYWORDS
1467        .iter()
1468        .filter(|k| k.word.chars().next().is_some_and(char::is_lowercase))
1469        .map(|k| Completion::item(k.word, CompletionKind::Keyword, Some(k.meaning.to_string())))
1470        .collect();
1471    for &(label, body) in SNIPPETS {
1472        out.push(Completion::snippet(label, body));
1473    }
1474    out
1475}
1476
1477// -- Enumeration (parse project sources + the embedded `bynk` surface) --
1478
1479/// Lex + recovery-parse one source, yielding its primary [`SourceUnit`]
1480/// (`None` when nothing parses — an empty or header-broken file).
1481fn parse_source_unit(src: &str) -> Option<SourceUnit> {
1482    let tokens = lexer::tokenize(src).ok()?;
1483    parser::parse_unit_with_recovery(&tokens, src).0
1484}
1485
1486/// The embedded first-party surface, parsed **once** for the whole process.
1487/// These are compile-time `include_str!` constants, so their parse is fixed;
1488/// re-lexing and re-parsing all five on every keystroke-driven request was
1489/// pure waste (#733). The `bynk` surface, the `bynk.cloudflare` platform
1490/// adapter, and the stdlib commons (`bynk.list`/`bynk.map`/`bynk.string`) whose
1491/// free fns are enumerable for `uses`-imported completion (G5) and signature
1492/// help. Harmless to the other contexts — the commons declare only `fn`s (no
1493/// types/capabilities) and are never a `consumes` target.
1494static EMBEDDED_UNITS: LazyLock<Vec<Arc<SourceUnit>>> = LazyLock::new(|| {
1495    // The single first-party source list (`bynk-check::firstparty`), so a new
1496    // first-party commons is completion-visible without a second edit here
1497    // (#901 — `bynk.locale`/`bynk.locale.types` were missing from this copy).
1498    bynk_check::firstparty::FIRSTPARTY_SOURCES
1499        .iter()
1500        .filter_map(|(_, src)| parse_source_unit(src).map(Arc::new))
1501        .collect()
1502});
1503
1504/// A cached parse of one project file's content, tagged with the exact
1505/// content string it was parsed from. Content-ownership track (#1086) slice
1506/// 0+1: `content` is supplied by the caller (`bynk-lsp`'s overlay-then-disk
1507/// sweep, `bynk_lsp::content::sweep_project_content`) rather than read here —
1508/// this cache no longer touches disk at all, so an open buffer's unsaved edit
1509/// (which has no meaningful mtime) invalidates it exactly like a saved one.
1510struct CachedUnit {
1511    content: Arc<str>,
1512    unit: Option<Arc<SourceUnit>>,
1513}
1514
1515/// Per-file project-source parse cache, keyed by absolute path. Shared across
1516/// requests and worker threads (completion/signature-help/hover all enumerate
1517/// the same project sources). Keyed on the path so distinct fixtures/projects
1518/// never collide, and invalidated by content equality so any change — saved
1519/// or an unsaved buffer edit alike — is picked up.
1520static PROJECT_UNIT_CACHE: LazyLock<Mutex<HashMap<PathBuf, CachedUnit>>> =
1521    LazyLock::new(|| Mutex::new(HashMap::new()));
1522
1523/// Cap on distinct cached files. Without it, a long-lived server hopping across
1524/// many workspaces accumulates one entry per path ever enumerated — and
1525/// renamed/deleted files leave dangling `None`s behind (#776 review). Past the
1526/// cap the cache is cleared wholesale: crude, but entries repopulate lazily on
1527/// the next access, and a project with this many files is already far past where
1528/// a per-keystroke parse cache pays off. Generous, so a normal project never
1529/// trips it.
1530const PROJECT_UNIT_CACHE_CAP: usize = 4096;
1531
1532/// The parsed unit for a project file's already-read `content`, from the
1533/// cache when `content` is byte-identical to what was last parsed for `path`,
1534/// else parsed fresh and stored. A file whose content fails to parse caches a
1535/// `None` unit under its (now current) content, so a transient parse failure
1536/// is not retried on every request either.
1537fn cached_project_unit(path: &Path, content: &str) -> Option<Arc<SourceUnit>> {
1538    {
1539        let cache = PROJECT_UNIT_CACHE.lock().unwrap();
1540        if let Some(entry) = cache.get(path)
1541            && &*entry.content == content
1542        {
1543            return entry.unit.clone();
1544        }
1545    }
1546    let unit = parse_source_unit(content).map(Arc::new);
1547    let mut cache = PROJECT_UNIT_CACHE.lock().unwrap();
1548    // Bound the cache: a fresh path past the cap clears it rather than growing
1549    // without limit (refreshing an existing entry never grows the map).
1550    if cache.len() >= PROJECT_UNIT_CACHE_CAP && !cache.contains_key(path) {
1551        cache.clear();
1552    }
1553    cache.insert(
1554        path.to_path_buf(),
1555        CachedUnit {
1556            content: Arc::from(content),
1557            unit: unit.clone(),
1558        },
1559    );
1560    unit
1561}
1562
1563/// Parse every project unit, plus the embedded first-party adapters (the
1564/// `bynk` surface and the `bynk.cloudflare` platform adapter), and call `f`
1565/// for each. Recovery parsing tolerates the in-progress edit at the cursor.
1566///
1567/// The embedded surface is parsed once (`EMBEDDED_UNITS`) and the project's
1568/// other files are served from a per-file parse cache (`cached_project_unit`),
1569/// keyed on content rather than disk metadata (content-ownership track,
1570/// #1086, slice 0+1) — `files` is a pre-read `(path, content)` map the caller
1571/// (`bynk-lsp`) built by overlaying every open buffer over a disk sweep, so
1572/// an unsaved edit to file A is visible here exactly like a saved one. Only
1573/// `doc_text` — the buffer under the cursor, which changes per keystroke — is
1574/// parsed fresh each call (#733), never looked up in `files` (the caller
1575/// excludes the cursor's own file from `files` for exactly this reason).
1576/// Ordering is preserved (embedded, then the buffer, then the other files) so
1577/// a callback's first-name-wins dedup still prefers the live buffer.
1578pub fn for_each_unit(
1579    doc_text: &str,
1580    files: Option<&HashMap<PathBuf, String>>,
1581    mut f: impl FnMut(&SourceUnit),
1582) {
1583    for unit in EMBEDDED_UNITS.iter() {
1584        f(unit);
1585    }
1586    if let Some(unit) = parse_source_unit(doc_text) {
1587        f(&unit);
1588    }
1589    // Slice A: the project's files come from the compiler's own discovery
1590    // (`bynk_ide::discover_files`) — every `include` root, `exclude` honoured.
1591    // This used to walk a single directory by hand, so completion could not see
1592    // a second root and swept `out`/`node_modules` if they sat beneath it.
1593    if let Some(content) = files {
1594        for (path, text) in content {
1595            if let Some(unit) = cached_project_unit(path, text) {
1596                f(&unit);
1597            }
1598        }
1599    }
1600}
1601
1602/// Consumable unit names: contexts and adapters (plus `bynk`), deduplicated.
1603fn consumable_units(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<Completion> {
1604    let mut seen: BTreeSet<String> = BTreeSet::new();
1605    let mut out: Vec<Completion> = Vec::new();
1606    for_each_unit(doc_text, files, |unit| {
1607        let (name, kind) = match unit {
1608            SourceUnit::Context(c) => (c.name.joined(), "context"),
1609            SourceUnit::Adapter(a) => (a.name.joined(), "adapter"),
1610            _ => return,
1611        };
1612        if seen.insert(name.clone()) {
1613            out.push(Completion::item(
1614                name,
1615                CompletionKind::Unit,
1616                Some(kind.to_string()),
1617            ));
1618        }
1619    });
1620    out
1621}
1622
1623/// The capability names a unit `exports capability`.
1624///
1625/// Finding #62: a first-wins guard, for the same buffer-vs-stale-disk-copy
1626/// reason as [`record_field_names`] — the `BTreeSet` still dedups a single
1627/// matching unit's own (possibly repeated) export clauses.
1628fn capabilities_of_unit(
1629    unit: &str,
1630    doc_text: &str,
1631    files: Option<&HashMap<PathBuf, String>>,
1632) -> Vec<String> {
1633    let mut out: BTreeSet<String> = BTreeSet::new();
1634    let mut found = false;
1635    for_each_unit(doc_text, files, |u| {
1636        if found {
1637            return;
1638        }
1639        let (name, exports) = match u {
1640            SourceUnit::Context(c) => (c.name.joined(), &c.exports),
1641            SourceUnit::Adapter(a) => (a.name.joined(), &a.exports),
1642            _ => return,
1643        };
1644        if name != unit {
1645            return;
1646        }
1647        found = true;
1648        for clause in exports {
1649            if clause.kind == ExportKind::Capability {
1650                for n in &clause.names {
1651                    out.insert(n.name.clone());
1652                }
1653            }
1654        }
1655    });
1656    out.into_iter().collect()
1657}
1658
1659/// Capabilities in scope for a `given` clause in the current document: locally
1660/// declared capabilities, bare names flattened by a braced `consumes`, and
1661/// `U.Cap` for each whole-unit `consumes U`.
1662fn in_scope_capabilities(
1663    doc_text: &str,
1664    files: Option<&HashMap<PathBuf, String>>,
1665) -> Vec<Completion> {
1666    let mut labels: BTreeSet<String> = BTreeSet::new();
1667    let Ok(tokens) = lexer::tokenize(doc_text) else {
1668        return Vec::new();
1669    };
1670    let (Some(unit), _errs) = parser::parse_unit_with_recovery(&tokens, doc_text) else {
1671        return Vec::new();
1672    };
1673    let (items, consumes) = match &unit {
1674        SourceUnit::Context(c) => (&c.items, &c.consumes),
1675        SourceUnit::Adapter(a) => (&a.items, &EMPTY_CONSUMES),
1676        _ => return Vec::new(),
1677    };
1678    // Locally declared capabilities.
1679    for item in items {
1680        if let bynk_syntax::ast::CommonsItem::Capability(c) = item {
1681            labels.insert(c.name.name.clone());
1682        }
1683    }
1684    // Consumed capabilities: flattened bare names, or qualified `U.Cap`.
1685    for c in consumes {
1686        let unit_name = c.target.joined();
1687        match &c.selected {
1688            Some(names) => {
1689                for n in names {
1690                    labels.insert(n.name.clone());
1691                }
1692            }
1693            None => {
1694                let prefix = c
1695                    .alias
1696                    .as_ref()
1697                    .map(|a| a.name.clone())
1698                    .unwrap_or_else(|| unit_name.clone());
1699                for cap in capabilities_of_unit(&unit_name, doc_text, files) {
1700                    labels.insert(format!("{prefix}.{cap}"));
1701                }
1702            }
1703        }
1704    }
1705    labels
1706        .into_iter()
1707        .map(|label| {
1708            Completion::item(
1709                label,
1710                CompletionKind::Capability,
1711                Some("capability in scope".to_string()),
1712            )
1713        })
1714        .collect()
1715}
1716
1717// -- Value-receiver `.method`/`.field` (slice 3, ADR 0063) --
1718
1719/// If the cursor (byte `offset` into `text`) sits just after a **lowercase**
1720/// `receiver.`(`partial`) — a *value* receiver — return the buffer **rewritten**
1721/// so the receiver is a complete expression (the trailing `.partial` dropped,
1722/// so the file parses) and the byte offset of the receiver to type. Returns
1723/// `None` for an uppercase name receiver (slice 2), a decimal `1.`, or a
1724/// `.`-qualified segment.
1725///
1726/// The rewrite is the spike's fix for the mid-edit parse: a bare `email.`
1727/// cascades and loses the receiver, but `email` (dot dropped) types cleanly.
1728pub fn value_receiver_rewrite(text: &str, offset: usize) -> Option<(String, usize)> {
1729    let prefix = text.get(..offset)?;
1730    let head = prefix
1731        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
1732        .strip_suffix('.')?;
1733    let (recv, start) = ident_ending_at(head, head.len())?;
1734    let first = recv.chars().next()?;
1735    if !(first.is_ascii_lowercase() || first == '_') {
1736        return None; // uppercase = name receiver (slice 2); a digit = a decimal
1737    }
1738    if head[..start].ends_with('.') {
1739        return None; // a `.`-qualified segment, not a bare value receiver
1740    }
1741    let dot = head.len(); // the receiver ends here; the dot was the next byte
1742    let rewritten = format!("{}{}", &text[..dot], &text[offset..]);
1743    Some((rewritten, dot.saturating_sub(1)))
1744}
1745
1746/// The identifier run ending at byte `end` in `text` — `(name, start)` — the
1747/// trim-back-to-a-non-identifier-boundary scan shared by every receiver/
1748/// identifier extraction in the LSP: [`value_receiver_rewrite`] above,
1749/// `symbols::receiver_segment_at` (a member's receiver, dot-preceded), and
1750/// `symbols::store_field_kind_at` (a bare receiver's own end offset, no dot).
1751/// Each has different preconditions on what precedes `end`, but the boundary
1752/// scan itself — walk back to the nearest non-identifier char, respecting
1753/// UTF-8 boundaries — is one definition, so it can't drift between them (a
1754/// review flagged the previous three near-identical copies). `None` if
1755/// nothing identifier-shaped precedes `end`.
1756pub fn ident_ending_at(text: &str, end: usize) -> Option<(&str, usize)> {
1757    let before = text.get(..end)?;
1758    let start = before
1759        .char_indices()
1760        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
1761        .map_or(0, |(i, c)| i + c.len_utf8());
1762    let name = &before[start..];
1763    (!name.is_empty()).then_some((name, start))
1764}
1765
1766/// The members of a typed value receiver: the built-in kernel methods of its
1767/// type (from the enumerable registry) plus, for a record, its fields.
1768pub fn value_member_candidates(
1769    ty: TyId,
1770    tys: &Types,
1771    doc_text: &str,
1772    files: Option<&HashMap<PathBuf, String>>,
1773) -> Vec<Completion> {
1774    let mut out: Vec<Completion> = kernel_methods::methods_for(ty, tys)
1775        .iter()
1776        .map(|km| {
1777            Completion::item(
1778                km.name,
1779                CompletionKind::Member,
1780                Some(km.signature.to_string()),
1781            )
1782        })
1783        .collect();
1784    // Record fields — resolve the receiver's named type to its declaration.
1785    if let Ty::Named { name, .. } = &*tys.get(ty) {
1786        let mut seen: BTreeSet<String> = BTreeSet::new();
1787        for_each_unit(doc_text, files, |unit| {
1788            let items = match unit {
1789                SourceUnit::Commons(c) => &c.items,
1790                SourceUnit::Context(c) => &c.items,
1791                SourceUnit::Adapter(a) => &a.items,
1792                _ => return,
1793            };
1794            for item in items {
1795                if let CommonsItem::Type(t) = item
1796                    && &t.name.name == name
1797                    && let TypeBody::Record(r) = &t.body
1798                {
1799                    for f in &r.fields {
1800                        if seen.insert(f.name.name.clone()) {
1801                            out.push(Completion::item(
1802                                f.name.name.clone(),
1803                                CompletionKind::Field,
1804                                Some(format!("field of `{name}`")),
1805                            ));
1806                        }
1807                    }
1808                }
1809            }
1810        });
1811    }
1812    out
1813}
1814
1815/// #596: the entry ops (and, for a `Map`, the `.entries`/`.keys`/`.values`
1816/// query accessors) of a bare `store` field receiver — merged onto
1817/// [`value_member_candidates`] so a store field offers its whole vocabulary,
1818/// not just the `Query` half `kernel_methods::methods_for` covers. A bare
1819/// store `Map` field types (via the checker's ADR 0120 "whole map as a value"
1820/// reading) to plain `Ty::Query`, indistinguishable from an ordinary
1821/// `Query`-typed local — so this reads the receiver's *provenance* instead,
1822/// the same way hover's `describe_store_op_at` does. Empty when the receiver
1823/// isn't a store field of an enclosing agent, or is shadowed by a local.
1824///
1825/// `rewritten`/`recv_offset` are [`value_receiver_rewrite`]'s output; `locals`
1826/// is the current round's locals for the file, best-effort (empty when the
1827/// analysed round doesn't match `rewritten`, in which case the shadowing
1828/// check simply sees no local in scope).
1829pub fn store_field_member_candidates(
1830    rewritten: &str,
1831    recv_offset: usize,
1832    locals: &[LocalBinding],
1833) -> Vec<Completion> {
1834    let Some((kind_head, held)) =
1835        crate::symbols::store_field_kind_at(rewritten, recv_offset + 1, locals)
1836    else {
1837        return Vec::new();
1838    };
1839    let mut out: Vec<Completion> = store_ops::ops_for(&kind_head)
1840        .iter()
1841        .map(|o| {
1842            Completion::item(
1843                o.name,
1844                CompletionKind::Member,
1845                Some(o.signature.to_string()),
1846            )
1847        })
1848        .collect();
1849    if kind_head == "Map" && !held {
1850        out.extend(store_ops::MAP_QUERY_ACCESSORS.iter().map(|a| {
1851            Completion::item(a.name, CompletionKind::Field, Some(a.signature.to_string()))
1852        }));
1853    }
1854    out
1855}
1856
1857static EMPTY_CONSUMES: Vec<bynk_syntax::ast::ConsumesDecl> = Vec::new();
1858
1859#[cfg(test)]
1860mod tests {
1861    use super::*;
1862    use bynk_check::firstparty::BYNK_LIST_SRC;
1863
1864    fn labels(line: &str, doc: &str) -> Vec<String> {
1865        complete(line, doc, None)
1866            .into_iter()
1867            .map(|c| c.label)
1868            .collect()
1869    }
1870
1871    #[test]
1872    fn consumes_target_suggests_units_including_bynk() {
1873        // An adapter in the open doc plus the always-available `bynk` surface.
1874        let doc = "adapter tokens {\n  binding \"./b.ts\"\n  capability Jwt { fn f() -> Effect[Int] }\n  provides Jwt = X\n}\n";
1875        let got = labels("  consumes ", doc);
1876        assert!(got.contains(&"bynk".to_string()), "{got:?}");
1877        assert!(got.contains(&"tokens".to_string()), "{got:?}");
1878    }
1879
1880    #[test]
1881    fn consumes_brace_suggests_that_units_capabilities() {
1882        let got = labels("  consumes bynk { ", "context a.b\n");
1883        // The embedded `bynk` surface exports these.
1884        assert!(got.contains(&"Clock".to_string()), "{got:?}");
1885        assert!(got.contains(&"Random".to_string()), "{got:?}");
1886        assert!(got.contains(&"Logger".to_string()), "{got:?}");
1887    }
1888
1889    #[test]
1890    fn given_suggests_local_and_flattened_capabilities() {
1891        let doc = "context a.b\n\
1892                   consumes bynk { Clock }\n\
1893                   capability Local { fn f() -> Effect[Int] }\n\
1894                   service s {\n\
1895                   on call() -> Effect[Int] given Clock {\n\
1896                   1\n\
1897                   }\n\
1898                   }\n";
1899        let got = labels("    on call() -> Effect[Int] given ", doc);
1900        assert!(got.contains(&"Clock".to_string()), "flattened: {got:?}");
1901        assert!(got.contains(&"Local".to_string()), "local: {got:?}");
1902    }
1903
1904    #[test]
1905    fn expression_position_offers_constructors_and_types() {
1906        // ADR 0093 D3/D5: a value position (after `=`) yields every constructor
1907        // keyword and in-scope type names — the entry to a static call or a
1908        // record construction. (Locals/params are appended handler-side, not by
1909        // `complete()`.) Registry-driven over CONSTRUCTORS.
1910        let doc = "commons m {\n  type Order = { id: Int }\n}\n";
1911        let items = complete("  let x = ", doc, None);
1912        for &c in CONSTRUCTORS {
1913            assert!(
1914                find(&items, c, CompletionKind::Constructor).is_some(),
1915                "constructor {c}: {:?}",
1916                items.iter().map(|i| &i.label).collect::<Vec<_>>()
1917            );
1918        }
1919        assert!(
1920            find(&items, "Int", CompletionKind::Type).is_some(),
1921            "builtin type"
1922        );
1923        assert!(
1924            find(&items, "Order", CompletionKind::Type).is_some(),
1925            "project type"
1926        );
1927    }
1928
1929    #[test]
1930    fn value_receiver_and_decimal_are_not_expression_positions() {
1931        // A trailing `x.`/`1.` is a member/decimal context, not an expression
1932        // start — `complete()` yields nothing (the value-receiver path is
1933        // handler-side; see `record_value_and_decimal_receivers_yield_nothing`).
1934        assert!(complete("  let p = q.", "context a.b\n", None).is_empty());
1935        assert!(complete("  let n = 1.", "context a.b\n", None).is_empty());
1936    }
1937
1938    /// Free `fn` names declared in a unit source (registry-driven test helper).
1939    fn free_fn_names(src: &str) -> Vec<String> {
1940        let tokens = lexer::tokenize(src).unwrap();
1941        let (unit, _) = parser::parse_unit_with_recovery(&tokens, src);
1942        let unit = unit.unwrap();
1943        let (items, _) = unit_items_and_uses(&unit);
1944        items
1945            .iter()
1946            .filter_map(|it| match it {
1947                CommonsItem::Fn(f) => match &f.name {
1948                    FnName::Free(id) => Some(id.name.clone()),
1949                    FnName::Method { .. } => None,
1950                },
1951                _ => None,
1952            })
1953            .collect()
1954    }
1955
1956    #[test]
1957    fn free_functions_offered_for_own_unit_and_used_modules() {
1958        // ADR 0093 D3/G5: expression position offers the current unit's own
1959        // free `fn`s and the combinators of every `uses`-imported module.
1960        let doc = "commons app {\n  uses bynk.list\n  fn helper(x: Int) -> Int { x }\n}\n";
1961        let items = complete("  let y = ", doc, None);
1962        // The current unit's own function.
1963        assert!(
1964            find(&items, "helper", CompletionKind::Function).is_some(),
1965            "own fn: {:?}",
1966            items.iter().map(|i| &i.label).collect::<Vec<_>>()
1967        );
1968        // Every combinator of the imported `bynk.list` — registry-driven over the
1969        // embedded source, so a new stdlib combinator must surface or this fails.
1970        for name in free_fn_names(BYNK_LIST_SRC) {
1971            assert!(
1972                find(&items, &name, CompletionKind::Function).is_some(),
1973                "bynk.list.{name}: {:?}",
1974                items.iter().map(|i| &i.label).collect::<Vec<_>>()
1975            );
1976        }
1977        // A module that is not imported does not leak its fns.
1978        assert!(
1979            find(&items, "values", CompletionKind::Function).is_none(),
1980            "bynk.map.values leaked without `uses bynk.map`"
1981        );
1982    }
1983
1984    #[test]
1985    fn locale_functions_offered_when_bynk_locale_is_used() {
1986        // #901: `bynk.locale` was absent from the embedded-source list, so its
1987        // builders/render never surfaced in completion even under `uses
1988        // bynk.locale`. Exercised through the real `complete()` path, not the
1989        // renderer alone.
1990        let doc = "commons app {\n  uses bynk.locale\n}\n";
1991        let items = complete("  let y = ", doc, None);
1992        for name in [
1993            "render",
1994            "message",
1995            "withText",
1996            "withWhole",
1997            "withNum",
1998            "withMoment",
1999        ] {
2000            assert!(
2001                find(&items, name, CompletionKind::Function).is_some(),
2002                "bynk.locale.{name} missing from completion: {:?}",
2003                items.iter().map(|i| &i.label).collect::<Vec<_>>()
2004            );
2005        }
2006    }
2007
2008    #[test]
2009    fn free_functions_require_a_uses_import() {
2010        // Own fns are always in scope; stdlib combinators only with their `uses`.
2011        let doc = "commons app {\n  fn helper(x: Int) -> Int { x }\n}\n";
2012        let items = complete("  let y = ", doc, None);
2013        assert!(find(&items, "helper", CompletionKind::Function).is_some());
2014        for name in ["map", "filter", "reverse"] {
2015            assert!(
2016                find(&items, name, CompletionKind::Function).is_none(),
2017                "bynk.list.{name} offered without `uses bynk.list`"
2018            );
2019        }
2020    }
2021
2022    #[test]
2023    fn member_completion_reaches_inside_an_interpolation_hole() {
2024        // v0.43: a `Type.`/`Cap.` receiver inside a `\(…)` hole completes just
2025        // as it does in bare expression position — context detection is purely
2026        // lexical, so the surrounding string and `\(` do not interfere.
2027        let doc = "context a.b\n  capability Timer { fn now() -> Effect[Int] }\n";
2028        let in_hole = complete("    \"the time is \\(Timer.", doc, None);
2029        assert!(
2030            find(&in_hole, "now", CompletionKind::Member).is_some(),
2031            "capability op not offered inside a hole: {:?}",
2032            in_hole.iter().map(|c| &c.label).collect::<Vec<_>>()
2033        );
2034        // A built-in static receiver works inside a hole too.
2035        let statics = complete("  \"n=\\(Int.", "context a.b\n", None);
2036        assert!(find(&statics, "parse", CompletionKind::Member).is_some());
2037    }
2038
2039    #[test]
2040    fn consumes_with_as_is_not_a_target_completion() {
2041        // `consumes X as ` is aliasing, not target-name completion.
2042        assert!(!is_consumes_target("consumes platform.time as "));
2043        assert!(is_consumes_target("consumes platform"));
2044    }
2045
2046    fn find<'a>(
2047        items: &'a [Completion],
2048        label: &str,
2049        kind: CompletionKind,
2050    ) -> Option<&'a Completion> {
2051        items.iter().find(|c| c.label == label && c.kind == kind)
2052    }
2053
2054    #[test]
2055    fn type_annotation_suggests_builtins_surface_and_project_types() {
2056        let doc = "commons m {\n  type Order = { id: Int }\n}\n";
2057        let got = labels("  let x: ", doc);
2058        // Built-ins (with registry docs), the `bynk`-surface transparent types,
2059        // and the project's own type declaration.
2060        for want in ["Int", "Option", "Result", "Effect", "List", "Map"] {
2061            assert!(got.contains(&want.to_string()), "built-in {want}: {got:?}");
2062        }
2063        assert!(got.contains(&"Uuid".to_string()), "surface: {got:?}");
2064        assert!(got.contains(&"Order".to_string()), "project: {got:?}");
2065    }
2066
2067    #[test]
2068    fn return_type_and_type_args_are_type_positions() {
2069        assert!(is_type_position("  on call() -> "));
2070        assert!(is_type_position("  let x: Option["));
2071        assert!(is_type_position("  let x: Result[Int, "));
2072        // A partial type name being typed still counts.
2073        assert!(is_type_position("  -> Eff"));
2074    }
2075
2076    #[test]
2077    fn list_literal_is_not_a_type_position() {
2078        // A bare `[` opening a list literal is an expression, not type args…
2079        assert!(!is_type_position("  let xs = ["));
2080        // …so it is an expression position: a list element is a value, and the
2081        // constructor keywords are offered there (ADR 0093 D3) — not a
2082        // type-argument completion.
2083        let items = complete("  let xs = [", "context a.b\n", None);
2084        assert!(
2085            find(&items, "Some", CompletionKind::Constructor).is_some(),
2086            "{:?}",
2087            items.iter().map(|c| &c.label).collect::<Vec<_>>()
2088        );
2089    }
2090
2091    #[test]
2092    fn builtin_type_carries_its_registry_doc() {
2093        let items = complete("  let x: ", "context a.b\n", None);
2094        let int = find(&items, "Int", CompletionKind::Type).expect("Int present");
2095        assert_eq!(int.detail.as_deref(), keyword_doc("Int"));
2096        assert!(int.detail.is_some(), "Int should have a doc");
2097    }
2098
2099    #[test]
2100    fn keyword_position_suggests_keywords_and_snippets() {
2101        let items = complete("  ", "context a.b\n", None);
2102        // Declaration/statement keywords, with docs.
2103        assert!(find(&items, "capability", CompletionKind::Keyword).is_some());
2104        assert!(find(&items, "fn", CompletionKind::Keyword).is_some());
2105        assert!(find(&items, "let", CompletionKind::Keyword).is_some());
2106        // Uppercase type/value names are *not* keyword-position candidates.
2107        assert!(find(&items, "Int", CompletionKind::Keyword).is_none());
2108        assert!(find(&items, "Some", CompletionKind::Keyword).is_none());
2109        // Snippets are offered alongside.
2110        let snip = find(&items, "service", CompletionKind::Snippet).expect("service snippet");
2111        let body = snip.insert_text.as_deref().unwrap_or("");
2112        assert!(body.contains("on call"), "snippet body: {body:?}");
2113        assert!(body.contains("${1"), "snippet tab stop: {body:?}");
2114    }
2115
2116    #[test]
2117    fn keyword_position_fires_on_an_empty_line() {
2118        assert!(is_keyword_position(""));
2119        assert!(is_keyword_position("  cap"));
2120        assert!(!is_keyword_position("  let x ="));
2121        assert!(!is_keyword_position("  x: "));
2122        assert!(!complete("", "context a.b\n", None).is_empty());
2123    }
2124
2125    #[test]
2126    fn member_receiver_is_a_single_upper_ident_before_a_dot() {
2127        assert_eq!(member_receiver("  Color."), Some("Color".to_string()));
2128        assert_eq!(
2129            member_receiver("  let e = Email.o"),
2130            Some("Email".to_string())
2131        );
2132        assert_eq!(member_receiver("  x."), None); // lowercase = value receiver (slice 3)
2133        assert_eq!(member_receiver("  1."), None); // decimal literal, not a member access
2134        assert_eq!(member_receiver("  a.B."), None); // `.`-qualified segment
2135        assert_eq!(member_receiver("  Color"), None); // no dot yet
2136    }
2137
2138    #[test]
2139    fn receiver_extraction_survives_a_multibyte_char_before_the_receiver() {
2140        // A multi-byte non-identifier char immediately before the receiver used
2141        // to make the `i + 1` byte offset land mid-codepoint → panic on slice
2142        // (#715, hit on every completion/hover keystroke inside a string).
2143        assert_eq!(member_receiver("\"Foo."), Some("Foo".to_string()));
2144        assert_eq!(member_receiver("€Color."), Some("Color".to_string()));
2145        assert_eq!(member_receiver("—Bar."), Some("Bar".to_string()));
2146        assert_eq!(word_before_brace("\"cors {", 6), "cors");
2147        assert_eq!(
2148            record_construction_receiver("\"€Order {"),
2149            Some("Order".to_string())
2150        );
2151        // The reproduction from the issue: a completion request against a buffer
2152        // where the line prefix opens a string literal must not panic.
2153        let _ = complete("let x = \"Foo.", "commons m {}\n", None);
2154        let _ = complete("  \"€42.", "commons m {}\n", None);
2155        assert_eq!(
2156            value_receiver_rewrite("\"email.", 7).map(|(_, r)| r),
2157            Some(5),
2158        );
2159    }
2160
2161    #[test]
2162    fn sum_member_suggests_variants() {
2163        let doc = "commons m {\n  type Color = enum { Red, Green, Blue }\n}\n";
2164        let items = complete("  let c = Color.", doc, None);
2165        for v in ["Red", "Green", "Blue"] {
2166            assert!(
2167                find(&items, v, CompletionKind::Variant).is_some(),
2168                "variant {v}: {:?}",
2169                items.iter().map(|c| &c.label).collect::<Vec<_>>()
2170            );
2171        }
2172    }
2173
2174    #[test]
2175    fn refined_and_plain_alias_members_are_of_and_unsafe() {
2176        // A refinement-bearing type…
2177        let doc = "commons m {\n  type Email = String where NonEmpty\n}\n";
2178        let items = complete("  Email.", doc, None);
2179        assert!(find(&items, "of", CompletionKind::Member).is_some());
2180        assert!(find(&items, "unsafe", CompletionKind::Member).is_some());
2181        // …and a plain alias `type Id = Int` is *also* branded (the emitter
2182        // emits Id.of/Id.unsafe for every Refined body, refinement or not).
2183        let doc = "commons m {\n  type Id = Int\n}\n";
2184        assert!(find(&complete("  Id.", doc, None), "of", CompletionKind::Member).is_some());
2185    }
2186
2187    #[test]
2188    fn capability_member_suggests_ops() {
2189        let doc = "context a.b\n  capability Timer { fn now() -> Effect[Int]\n  fn at(t: Int) -> Effect[()] }\n";
2190        let items = complete("    Timer.", doc, None);
2191        let now = find(&items, "now", CompletionKind::Member).expect("`now` op offered");
2192        // Slice 5 detail polish: a typed signature (params + return), not bare
2193        // param names.
2194        assert_eq!(
2195            now.detail.as_deref(),
2196            Some("now() -> Effect[Int] — operation of `Timer`")
2197        );
2198        let at = find(&items, "at", CompletionKind::Member).expect("`at` op offered");
2199        assert_eq!(
2200            at.detail.as_deref(),
2201            Some("at(t: Int) -> Effect[()] — operation of `Timer`")
2202        );
2203    }
2204
2205    #[test]
2206    fn builtin_type_statics_are_offered() {
2207        assert!(
2208            find(
2209                &complete("  Int.", "context a.b\n", None),
2210                "parse",
2211                CompletionKind::Member
2212            )
2213            .is_some()
2214        );
2215        let j = complete("  Json.", "context a.b\n", None);
2216        assert!(find(&j, "encode", CompletionKind::Member).is_some());
2217        assert!(find(&j, "decode", CompletionKind::Member).is_some());
2218    }
2219
2220    #[test]
2221    fn builtin_sum_variants_are_complete() {
2222        // ADR 0093 D5/G3: every built-in sum variant in the AST registry must
2223        // surface on its name receiver. Registry-driven — adding an
2224        // `HttpResult`/`QueueResult` variant must appear in completion or this
2225        // fails (the standing drift guard, mirroring `kernel_registry`).
2226        let http: Vec<&str> = bynk_syntax::ast::HTTP_VARIANTS
2227            .iter()
2228            .map(|v| v.name)
2229            .collect();
2230        let queue: Vec<&str> = bynk_syntax::ast::QUEUE_VARIANTS
2231            .iter()
2232            .map(|v| v.name)
2233            .collect();
2234        for (recv, names) in [("HttpResult", http), ("QueueResult", queue)] {
2235            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2236            for name in names {
2237                assert!(
2238                    find(&items, name, CompletionKind::Variant).is_some(),
2239                    "{recv}.{name} missing: {:?}",
2240                    items.iter().map(|c| &c.label).collect::<Vec<_>>()
2241                );
2242            }
2243        }
2244    }
2245
2246    #[test]
2247    fn builtin_statics_are_reachable() {
2248        // ADR 0093 D5/G2: every BUILTIN_STATICS entry is reachable through the
2249        // name-receiver context — exercises the member_receiver→member_candidates
2250        // wiring for each receiver (e.g. that `Effect.`/`List.` are recognised).
2251        for &(recv, members) in BUILTIN_STATICS {
2252            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2253            for &(member, _) in members {
2254                assert!(
2255                    find(&items, member, CompletionKind::Member).is_some(),
2256                    "{recv}.{member} unreachable: {:?}",
2257                    items.iter().map(|c| &c.label).collect::<Vec<_>>()
2258                );
2259            }
2260        }
2261        // The slice-1 additions specifically — guards against a table regression
2262        // (the loop above can't catch an entry being deleted).
2263        for (recv, member) in [("List", "empty"), ("Map", "empty"), ("Effect", "pure")] {
2264            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2265            assert!(
2266                find(&items, member, CompletionKind::Member).is_some(),
2267                "{recv}.{member} missing from the statics table"
2268            );
2269        }
2270    }
2271
2272    #[test]
2273    fn record_value_and_decimal_receivers_yield_nothing() {
2274        // A record type has no name-receiver members (fields are value-receiver).
2275        let doc = "commons m {\n  type Point = { x: Int }\n}\n";
2276        assert!(complete("  Point.", doc, None).is_empty(), "record");
2277        // A lowercase value receiver is deferred to slice 3.
2278        assert!(complete("  let p = q.", doc, None).is_empty(), "value");
2279        // A decimal literal is not a member access.
2280        assert!(complete("  let n = 1.", doc, None).is_empty(), "decimal");
2281    }
2282
2283    #[test]
2284    fn value_receiver_rewrite_drops_the_dot_for_lowercase_receivers() {
2285        let text = "  let x = email.\n";
2286        let offset = text.find('.').unwrap() + 1; // just after the dot
2287        let (rewritten, recv) = value_receiver_rewrite(text, offset).expect("value receiver");
2288        assert_eq!(
2289            rewritten, "  let x = email\n",
2290            "the trailing dot is dropped"
2291        );
2292        assert!(
2293            text.get(recv..=recv).is_some_and(|c| c == "l"),
2294            "the receiver offset lands inside `email`"
2295        );
2296        // A partial member is dropped too.
2297        let text2 = "  let x = email.ma\n";
2298        let off2 = text2.find(".ma").unwrap() + 3;
2299        assert_eq!(
2300            value_receiver_rewrite(text2, off2).map(|(r, _)| r),
2301            Some("  let x = email\n".to_string())
2302        );
2303        // Uppercase (name receiver, slice 2), decimal, and no-dot yield None.
2304        assert!(value_receiver_rewrite("  Email.", 8).is_none());
2305        assert!(value_receiver_rewrite("  let n = 1.", 12).is_none());
2306        assert!(value_receiver_rewrite("  email", 7).is_none());
2307    }
2308
2309    /// One table for every fixture in this module — `Types` is `Send + Sync`
2310    /// (T3.6b), so a `LazyLock` static gives the `TyId`s below something to
2311    /// resolve against without threading a value through each helper.
2312    static TYS: std::sync::LazyLock<Types> = std::sync::LazyLock::new(Types::new);
2313
2314    #[test]
2315    fn value_member_candidates_lists_kernel_methods() {
2316        use bynk_syntax::ast::BaseType;
2317        let list = TYS.intern(Ty::List(TYS.intern(Ty::Base(BaseType::Int))));
2318        let items = value_member_candidates(list, &TYS, "context a.b\n", None);
2319        assert!(find(&items, "fold", CompletionKind::Member).is_some());
2320        assert!(find(&items, "get", CompletionKind::Member).is_some());
2321
2322        let string = TYS.intern(Ty::Base(BaseType::String));
2323        let items = value_member_candidates(string, &TYS, "context a.b\n", None);
2324        assert!(find(&items, "split", CompletionKind::Member).is_some());
2325        assert!(find(&items, "trim", CompletionKind::Member).is_some());
2326    }
2327
2328    #[test]
2329    fn value_member_candidates_lists_refined_inherited_kernel_methods() {
2330        // #561: a refined receiver offers its base type's read-only kernel
2331        // methods in `.`-member completion.
2332        use bynk_check::checker::NamedKind;
2333        use bynk_syntax::ast::BaseType;
2334        let name = TYS.intern(Ty::Named {
2335            name: "Name".to_string(),
2336            kind: NamedKind::Refined(BaseType::String),
2337            args: Vec::new(),
2338        });
2339        let items = value_member_candidates(
2340            name,
2341            &TYS,
2342            "commons m {\n  type Name = String where NonEmpty\n}\n",
2343            None,
2344        );
2345        assert!(find(&items, "toUpper", CompletionKind::Member).is_some());
2346        assert!(find(&items, "length", CompletionKind::Member).is_some());
2347    }
2348
2349    #[test]
2350    fn expression_position_offers_locals() {
2351        // Value-expecting positions (locals offered).
2352        assert!(is_expression_position("  let y = "));
2353        assert!(is_expression_position("  let y = a + lo")); // after a binary op
2354        assert!(is_expression_position("  f("));
2355        assert!(is_expression_position("  g(a, "));
2356        assert!(is_expression_position("  xs.fold(0, (acc, x) => ac")); // lambda body
2357        // `let y = foo` is still a value position (you're typing the value).
2358        assert!(is_expression_position("  let y = foo"));
2359        // Not value positions.
2360        assert!(!is_expression_position("  let y: ")); // type annotation
2361        assert!(!is_expression_position("  on call() -> ")); // return type
2362        assert!(!is_expression_position("  tot")); // bare line start (keyword position covers it)
2363    }
2364
2365    #[test]
2366    fn value_member_candidates_lists_record_fields() {
2367        use bynk_check::checker::NamedKind;
2368        let order = TYS.intern(Ty::Named {
2369            name: "Order".to_string(),
2370            kind: NamedKind::Record,
2371            args: Vec::new(),
2372        });
2373        let doc = "commons m {\n  type Order = { id: Int, total: Int }\n}\n";
2374        let items = value_member_candidates(order, &TYS, doc, None);
2375        assert!(
2376            find(&items, "id", CompletionKind::Field).is_some(),
2377            "{items:?}",
2378            items = items.iter().map(|c| &c.label).collect::<Vec<_>>()
2379        );
2380        assert!(find(&items, "total", CompletionKind::Field).is_some());
2381    }
2382
2383    #[test]
2384    fn store_field_member_candidates_offers_entry_ops_and_query_accessors() {
2385        // #596: a bare `store Map` field receiver offers its entry ops
2386        // (put/get/…) AND the `.entries`/`.keys`/`.values` accessors — the
2387        // vocabulary `value_member_candidates` alone can't see, since the
2388        // checker types a bare store map as plain `Ty::Query` (ADR 0120).
2389        let doc = "context shop\n\nagent Inventory {\n  key id: String\n  store items: Map[String, Int]\n\n  on call f() -> Effect[()] {\n    items.\n  }\n}\n";
2390        let offset = doc.find("items.").unwrap() + "items.".len();
2391        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2392        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2393        assert!(
2394            find(&items, "put", CompletionKind::Member).is_some(),
2395            "{items:?}",
2396            items = items.iter().map(|c| &c.label).collect::<Vec<_>>()
2397        );
2398        assert!(find(&items, "get", CompletionKind::Member).is_some());
2399        assert!(find(&items, "update", CompletionKind::Member).is_some());
2400        assert!(find(&items, "entries", CompletionKind::Field).is_some());
2401        assert!(find(&items, "keys", CompletionKind::Field).is_some());
2402        assert!(find(&items, "values", CompletionKind::Field).is_some());
2403    }
2404
2405    #[test]
2406    fn store_field_member_candidates_empty_for_ordinary_local() {
2407        // An ordinary local named the same as no store field yields nothing —
2408        // the receiver's provenance, not its name alone, drives this path.
2409        let doc = "context shop\n\nagent Inventory {\n  key id: String\n\n  on call f() -> Effect[()] {\n    let items = 1\n    items.\n  }\n}\n";
2410        let offset = doc.rfind("items.").unwrap() + "items.".len();
2411        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2412        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2413        assert!(items.is_empty());
2414    }
2415
2416    #[test]
2417    fn store_field_member_candidates_skips_query_accessors_on_held_map() {
2418        // ADR 0184: a held `Map[K, Connection]` never offers the key-query
2419        // accessors, only its entry ops (`put`/`get`/…).
2420        let doc = "context shop\n\nagent Room {\n  key id: String\n  store conns: Map[String, Connection[String]]\n\n  on call f() -> Effect[()] {\n    conns.\n  }\n}\n";
2421        let offset = doc.find("conns.").unwrap() + "conns.".len();
2422        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2423        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2424        assert!(find(&items, "put", CompletionKind::Member).is_some());
2425        assert!(find(&items, "entries", CompletionKind::Field).is_none());
2426        assert!(find(&items, "keys", CompletionKind::Field).is_none());
2427        assert!(find(&items, "values", CompletionKind::Field).is_none());
2428    }
2429
2430    #[test]
2431    fn store_field_member_candidates_offers_set_and_cache_vocabularies() {
2432        // Not every store kind gets the Map-only accessors, but every kind
2433        // gets its own entry ops.
2434        let doc = "context shop\n\nagent Inventory {\n  key id: String\n  store tags: Set[String]\n\n  on call f() -> Effect[()] {\n    tags.\n  }\n}\n";
2435        let offset = doc.find("tags.").unwrap() + "tags.".len();
2436        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2437        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2438        assert!(find(&items, "add", CompletionKind::Member).is_some());
2439        assert!(find(&items, "entries", CompletionKind::Field).is_none());
2440    }
2441
2442    // -- v0.124 (slice 3): the non-keyword completion contexts --
2443
2444    #[test]
2445    fn record_construction_offers_field_names() {
2446        let doc = "commons m {\n  type Order = { id: Int, total: Int }\n}\n";
2447        let got = labels("  let o = Order { ", doc);
2448        assert!(got.contains(&"id".to_string()), "{got:?}");
2449        assert!(got.contains(&"total".to_string()), "{got:?}");
2450        // After a comma, still field-name position.
2451        let got2 = labels("  let o = Order { id: 1, ", doc);
2452        assert!(got2.contains(&"total".to_string()), "{got2:?}");
2453        // After a `:`, it is a field *type* position, not a field name.
2454        assert!(record_construction_receiver("  let o = Order { id: ").is_none());
2455        // A lowercase brace context (a block) is not a construction.
2456        assert!(record_construction_receiver("  if x { ").is_none());
2457    }
2458
2459    #[test]
2460    fn from_offers_protocols() {
2461        let got = labels("  service s from ", "context a.b\n");
2462        assert!(got.contains(&"http".to_string()), "{got:?}");
2463        assert!(got.contains(&"cron".to_string()) && got.contains(&"queue".to_string()));
2464    }
2465
2466    #[test]
2467    fn on_offers_handler_kinds() {
2468        let got = labels("  on ", "context a.b\n");
2469        assert!(got.contains(&"call".to_string()), "{got:?}");
2470        assert!(got.contains(&"GET".to_string()) && got.contains(&"schedule".to_string()));
2471    }
2472
2473    #[test]
2474    fn by_offers_project_actors() {
2475        let doc = "context a.b\n\nactor Caller { auth = Bearer }\n";
2476        let got = labels("    by ", doc);
2477        assert!(got.contains(&"Caller".to_string()), "{got:?}");
2478    }
2479
2480    #[test]
2481    fn exports_offers_export_kinds() {
2482        let got = labels("  exports ", "adapter t {\n  binding \"./b.ts\"\n}\n");
2483        assert!(got.contains(&"capability".to_string()), "{got:?}");
2484        assert!(got.contains(&"transparent".to_string()));
2485    }
2486
2487    #[test]
2488    fn provides_offers_in_scope_capabilities() {
2489        let doc = "context a.b\n\ncapability Store { fn get() -> Effect[Int] }\n";
2490        let got = labels("  provides ", doc);
2491        assert!(got.contains(&"Store".to_string()), "{got:?}");
2492    }
2493
2494    #[test]
2495    fn where_offers_predicate_names() {
2496        // Type-decl refinement.
2497        let got = labels("  type Code = Int where ", "context a.b\n");
2498        assert!(got.contains(&"InRange".to_string()), "{got:?}");
2499        assert!(got.contains(&"NonNegative".to_string()));
2500        // #472: a match arm's `_ where <predicate>` shares the same catalogue.
2501        let got = labels("      _ where ", "context a.b\n");
2502        assert!(got.contains(&"Matches".to_string()), "{got:?}");
2503        assert!(got.contains(&"NonEmpty".to_string()));
2504    }
2505
2506    #[test]
2507    fn is_for_all_where_matches_only_the_for_all_binder() {
2508        assert!(is_for_all_where("\tfor all x: Int, y: Int where "));
2509        assert!(is_for_all_where("for all x: Int where "));
2510        // `for`/`all` must be standalone words, not prefixes of longer
2511        // identifiers.
2512        assert!(!is_for_all_where("format(x) where "));
2513        assert!(!is_for_all_where("for allocate x where "));
2514        // A type-decl or match-arm `where` isn't a `for all` binder.
2515        assert!(!is_for_all_where("  type Code = Int where "));
2516        assert!(!is_for_all_where("      _ where "));
2517        assert!(!is_for_all_where("no where here"));
2518    }
2519
2520    #[test]
2521    fn for_all_where_falls_through_to_expression_position_not_predicates() {
2522        // #472 (finding 2, PR #827 review): a `for all` binder's `where`
2523        // clause takes an arbitrary `Bool`, not the predicate catalogue —
2524        // it must not offer `InRange`/`NonNegative`/etc.
2525        let got = labels("\tfor all x: Int where ", "context a.b\n");
2526        assert!(!got.contains(&"InRange".to_string()), "{got:?}");
2527        assert!(!got.contains(&"NonNegative".to_string()), "{got:?}");
2528    }
2529
2530    #[test]
2531    fn clause_detectors_do_not_over_fire() {
2532        // `on` inside a larger word, and a field named `from`, must not trigger.
2533        assert!(!after_clause_keyword("  session ", "on"));
2534        assert!(!after_clause_keyword("  let from = ", "from"));
2535        // A standalone keyword does.
2536        assert!(after_clause_keyword("  service s from ", "from"));
2537        assert!(after_clause_keyword("    by ", "by"));
2538    }
2539
2540    #[test]
2541    fn contract_clause_kind_detects_requires_and_ensures() {
2542        assert_eq!(contract_clause_kind("  requires positive: "), Some(false));
2543        assert_eq!(contract_clause_kind("  ensures never_neg: "), Some(true));
2544        // Not a contract clause.
2545        assert_eq!(contract_clause_kind("  id: Int"), None);
2546        assert_eq!(contract_clause_kind("  let x = 1"), None);
2547    }
2548
2549    #[test]
2550    fn cors_field_position_inside_cors_block() {
2551        // Cursor at a fresh field-name line inside a `cors { }` block.
2552        let doc = "service api from http {\n  cors {\n    ";
2553        assert!(in_cors_field_position(doc, doc.len()));
2554        // After a `:` it is a value position, not a field-name position.
2555        let doc2 = "service api from http {\n  cors {\n    origins: ";
2556        assert!(!in_cors_field_position(doc2, doc2.len()));
2557        // Not inside a `cors` block (an ordinary record construction) — no.
2558        let doc3 = "let x = Order {\n    ";
2559        assert!(!in_cors_field_position(doc3, doc3.len()));
2560    }
2561
2562    #[test]
2563    fn security_field_position_inside_security_block() {
2564        // Cursor at a fresh field-name line inside a `security { }` block.
2565        let doc = "service api from http {\n  security {\n    ";
2566        assert!(in_security_field_position(doc, doc.len()));
2567        // After a `:` it is a value position, not a field-name position.
2568        let doc2 = "service api from http {\n  security {\n    hsts: ";
2569        assert!(!in_security_field_position(doc2, doc2.len()));
2570        // A `cors { }` block is not a `security` block.
2571        let doc3 = "service api from http {\n  cors {\n    ";
2572        assert!(!in_security_field_position(doc3, doc3.len()));
2573    }
2574
2575    /// Non-ASCII text inside the block (a `-- café` comment) must not break
2576    /// the field-position probes; a mid-codepoint offset degrades to `false`
2577    /// instead of panicking the request handler.
2578    #[test]
2579    fn field_position_probes_survive_non_ascii() {
2580        let doc = "service api from http {\n  cors { -- café\n    ";
2581        assert!(in_cors_field_position(doc, doc.len()));
2582        // A raw byte offset that lands inside the `é` must not panic.
2583        let mid = doc.find('é').unwrap() + 1;
2584        assert!(!doc.is_char_boundary(mid));
2585        let _ = in_cors_field_position(doc, mid);
2586        let _ = in_security_field_position(doc, mid);
2587        let _ = in_limits_field_position(doc, mid);
2588    }
2589
2590    #[test]
2591    fn limits_field_position_inside_limits_block() {
2592        // Cursor at a fresh field-name line inside a `limits { }` block.
2593        let doc = "service api from http {\n  limits {\n    ";
2594        assert!(in_limits_field_position(doc, doc.len()));
2595        // After a `:` it is a value position, not a field-name position.
2596        let doc2 = "service api from http {\n  limits {\n    maxBody: ";
2597        assert!(!in_limits_field_position(doc2, doc2.len()));
2598        // A `security { }` block is not a `limits` block.
2599        let doc3 = "service api from http {\n  security {\n    ";
2600        assert!(!in_limits_field_position(doc3, doc3.len()));
2601    }
2602
2603    #[test]
2604    fn service_body_item_position_offers_cors() {
2605        // A bare-word item start inside a `service … {` block.
2606        let doc = "service api from http {\n  ";
2607        assert!(in_service_body_item_position(doc, doc.len(), "  "));
2608        // Inside a nested `cors { }` block the innermost brace is not the
2609        // service brace, so the service-item cell does not fire.
2610        let doc2 = "service api from http {\n  cors {\n    ";
2611        assert!(!in_service_body_item_position(doc2, doc2.len(), "    "));
2612    }
2613
2614    #[test]
2615    fn cache_arg_position_inside_cache_annotation() {
2616        // Cursor at a fresh argument-name position inside `@cache( … )`.
2617        let doc = "service api from http {\n  @cache(";
2618        assert!(in_cache_arg_position(doc, doc.len()));
2619        // After a first arg + comma, still an argument-name position.
2620        let doc2 = "service api from http {\n  @cache(maxAge: 5.minutes, ";
2621        assert!(in_cache_arg_position(doc2, doc2.len()));
2622        // After a `:` it is a value position, not an argument-name position.
2623        let doc3 = "service api from http {\n  @cache(maxAge: ";
2624        assert!(!in_cache_arg_position(doc3, doc3.len()));
2625        // A bare `cache(` call (no `@`) is not the annotation.
2626        let doc4 = "let x = cache(";
2627        assert!(!in_cache_arg_position(doc4, doc4.len()));
2628        // An ordinary handler param list is not a `@cache` position.
2629        let doc5 = "on GET(\"/x\") by v: Visitor (";
2630        assert!(!in_cache_arg_position(doc5, doc5.len()));
2631    }
2632
2633    #[test]
2634    fn limit_arg_position_inside_limit_annotation() {
2635        // Cursor at a fresh argument-name position inside `@limit( … )`.
2636        let doc = "service api from http {\n  @limit(";
2637        assert!(in_limit_arg_position(doc, doc.len()));
2638        // After a first arg + comma, still an argument-name position.
2639        let doc2 = "service api from http {\n  @limit(maxBody: 1048576, ";
2640        assert!(in_limit_arg_position(doc2, doc2.len()));
2641        // After a `:` it is a value position, not an argument-name position.
2642        let doc3 = "service api from http {\n  @limit(maxBody: ";
2643        assert!(!in_limit_arg_position(doc3, doc3.len()));
2644        // A bare `limit(` call (no `@`) is not the annotation.
2645        let doc4 = "let x = limit(";
2646        assert!(!in_limit_arg_position(doc4, doc4.len()));
2647        // An ordinary handler param list is not a `@limit` position.
2648        let doc5 = "on GET(\"/x\") by v: Visitor (";
2649        assert!(!in_limit_arg_position(doc5, doc5.len()));
2650    }
2651
2652    #[test]
2653    fn sum_type_variants_lists_variants() {
2654        let doc = "commons m {\n  type Status = enum { Pending, Shipped }\n}\n";
2655        let got: Vec<String> = sum_type_variants("Status", doc, None)
2656            .into_iter()
2657            .map(|c| c.label)
2658            .collect();
2659        assert!(got.contains(&"Pending".to_string()), "{got:?}");
2660        assert!(got.contains(&"Shipped".to_string()), "{got:?}");
2661    }
2662
2663    #[test]
2664    fn variants_for_ty_offers_built_in_result_and_option() {
2665        // v0.145 (ADR 0169, base gap): a `Result`/`Option` scrutinee offers its
2666        // built-in variants, which are not declared types (`sum_type_variants`
2667        // can't see them). This is what makes match-arm completion fire for a
2668        // Result/Option scrutinee at all.
2669        use bynk_syntax::ast::BaseType;
2670        let result = TYS.intern(Ty::Result(
2671            TYS.intern(Ty::Base(BaseType::Int)),
2672            TYS.intern(Ty::Base(BaseType::String)),
2673        ));
2674        let got: Vec<String> = variants_for_ty(result, &TYS, "", None)
2675            .into_iter()
2676            .map(|c| c.label)
2677            .collect();
2678        assert_eq!(got, vec!["Ok".to_string(), "Err".to_string()]);
2679
2680        let option = TYS.intern(Ty::Option(TYS.intern(Ty::Base(BaseType::Int))));
2681        let got: Vec<String> = variants_for_ty(option, &TYS, "", None)
2682            .into_iter()
2683            .map(|c| c.label)
2684            .collect();
2685        assert_eq!(got, vec!["Some".to_string(), "None".to_string()]);
2686    }
2687
2688    #[test]
2689    fn nested_variant_completions_resolves_the_payload_type() {
2690        use bynk_check::checker::NamedKind;
2691        use bynk_syntax::ast::BaseType;
2692        // Built-in outer: `Some(‸)` on `Option[Result[Int, E]]` offers the
2693        // payload `Result`'s variants.
2694        let payload = TYS.intern(Ty::Result(
2695            TYS.intern(Ty::Base(BaseType::Int)),
2696            TYS.intern(Ty::Named {
2697                name: "E".to_string(),
2698                kind: NamedKind::Sum,
2699                args: Vec::new(),
2700            }),
2701        ));
2702        let scrut = TYS.intern(Ty::Option(payload));
2703        let got: Vec<String> = nested_variant_completions(scrut, &TYS, "Some", "", None)
2704            .into_iter()
2705            .map(|c| c.label)
2706            .collect();
2707        assert_eq!(got, vec!["Ok".to_string(), "Err".to_string()]);
2708
2709        // The other outer variant of a Result payload resolves the error arm.
2710        let opt_int = TYS.intern(Ty::Option(TYS.intern(Ty::Base(BaseType::Int))));
2711        let inner = TYS.intern(Ty::Result(TYS.intern(Ty::Base(BaseType::Int)), opt_int));
2712        let got: Vec<String> = nested_variant_completions(inner, &TYS, "Err", "", None)
2713            .into_iter()
2714            .map(|c| c.label)
2715            .collect();
2716        assert_eq!(got, vec!["Some".to_string(), "None".to_string()]);
2717
2718        // User-sum outer: `Wrap(‸)` on a declared sum offers the payload field
2719        // type's variants, walked from source.
2720        let doc = "commons m {\n  type Inner = enum { A, B }\n  \
2721                   type Outer = | Wrap(inner: Inner) | Bare\n}\n";
2722        let outer = TYS.intern(Ty::Named {
2723            name: "Outer".to_string(),
2724            kind: NamedKind::Sum,
2725            args: Vec::new(),
2726        });
2727        let got: Vec<String> = nested_variant_completions(outer, &TYS, "Wrap", doc, None)
2728            .into_iter()
2729            .map(|c| c.label)
2730            .collect();
2731        assert!(got.contains(&"A".to_string()), "{got:?}");
2732        assert!(got.contains(&"B".to_string()), "{got:?}");
2733    }
2734
2735    #[test]
2736    fn literal_kind_scrutinee_suggests_no_variants() {
2737        // v0.130: a literal-kind `match` (a primitive `Int`/`String`/`Bool`
2738        // scrutinee, or a refinement over one) has no variant names to offer.
2739        // A primitive types to `Ty::Base`, which never reaches this lookup; a
2740        // refined scrutinee types to `Ty::Named` but has no `enum` body, so the
2741        // variant candidate set is empty — no bogus completions.
2742        let doc = "commons m {\n  type Quantity = Int where InRange(1, 99)\n}\n";
2743        assert!(sum_type_variants("Quantity", doc, None).is_empty());
2744        assert!(sum_type_variants("Int", doc, None).is_empty());
2745    }
2746
2747    // -- parse cache (#733) --
2748
2749    /// Collect the enumerated unit names for a buffer + project file set.
2750    fn enumerated_units(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<String> {
2751        let mut names = Vec::new();
2752        for_each_unit(doc_text, files, |u| names.push(u.name().joined()));
2753        names
2754    }
2755
2756    /// Content-ownership track (#1086) slice 0+1: `files` is now a pre-read
2757    /// `(path, content)` map — the caller's job (`bynk-lsp`'s overlay-then-disk
2758    /// sweep) — rather than a bare path list `cached_project_unit` used to
2759    /// read from disk itself. These tests build that map directly; none of
2760    /// them touch the real filesystem any more.
2761    fn synthetic_path(name: &str) -> PathBuf {
2762        PathBuf::from(format!("/synthetic/{name}.bynk"))
2763    }
2764
2765    #[test]
2766    fn for_each_unit_yields_embedded_buffer_and_project_files() {
2767        let sibling = synthetic_path("sibling");
2768        let files = HashMap::from([(
2769            sibling,
2770            "commons proj.sibling {\n  fn s() -> Int { 1 }\n}\n".to_string(),
2771        )]);
2772
2773        let names = enumerated_units(
2774            "commons proj.buffer {\n  fn b() -> Int { 1 }\n}\n",
2775            Some(&files),
2776        );
2777        // The embedded `bynk` surface, the live buffer, and the project file all show.
2778        assert!(names.iter().any(|n| n == "bynk"), "embedded: {names:?}");
2779        assert!(
2780            names.iter().any(|n| n == "proj.buffer"),
2781            "buffer: {names:?}"
2782        );
2783        assert!(
2784            names.iter().any(|n| n == "proj.sibling"),
2785            "project file: {names:?}"
2786        );
2787    }
2788
2789    /// Finding #62: when the buffer and a project file both declare a type
2790    /// under the same name — the buffer's stale copy, or two distinct files
2791    /// that happen to collide — `record_field_names` and `sum_type_variants`
2792    /// must take the first match (the buffer, since `for_each_unit` yields it
2793    /// before `files`) rather than unioning both, so a field/variant removed
2794    /// from the live buffer does not resurface from the stale copy.
2795    #[test]
2796    fn same_named_declarations_do_not_union_across_units() {
2797        let stale = HashMap::from([(
2798            synthetic_path("stale"),
2799            "commons m {\n  \
2800             type Rec = { total: Int, old_field: Int }\n  \
2801             type Status = enum { Pending, Retired }\n\
2802             }\n"
2803            .to_string(),
2804        )]);
2805
2806        let buffer = "commons m {\n  \
2807                       type Rec = { total: Int }\n  \
2808                       type Status = enum { Pending, Shipped }\n\
2809                       }\n";
2810        let files = Some(&stale);
2811
2812        let fields: Vec<String> = record_field_names("Rec", buffer, files)
2813            .into_iter()
2814            .map(|c| c.label)
2815            .collect();
2816        assert_eq!(fields, vec!["total".to_string()], "{fields:?}");
2817
2818        let variants: Vec<String> = sum_type_variants("Status", buffer, files)
2819            .into_iter()
2820            .map(|c| c.label)
2821            .collect();
2822        assert_eq!(
2823            variants,
2824            vec!["Pending".to_string(), "Shipped".to_string()],
2825            "{variants:?}"
2826        );
2827    }
2828
2829    /// Finding #62: `capabilities_of_unit` takes the first unit named `unit`
2830    /// rather than unioning exports across every matching unit — the same
2831    /// buffer-vs-stale-copy reasoning as
2832    /// `same_named_declarations_do_not_union_across_units`.
2833    #[test]
2834    fn capabilities_of_unit_does_not_union_across_units() {
2835        let stale = HashMap::from([(
2836            synthetic_path("stale"),
2837            "adapter tokens {\n  \
2838             exports capability { Jwt, Retired }\n\
2839             }\n"
2840            .to_string(),
2841        )]);
2842
2843        let buffer = "adapter tokens {\n  exports capability { Jwt }\n}\n";
2844        let files = Some(&stale);
2845
2846        let caps = capabilities_of_unit("tokens", buffer, files);
2847        assert_eq!(caps, vec!["Jwt".to_string()], "{caps:?}");
2848    }
2849
2850    #[test]
2851    fn project_unit_cache_invalidates_on_change() {
2852        let path = synthetic_path("unit");
2853
2854        let first_files = HashMap::from([(
2855            path.clone(),
2856            "commons proj.first {\n  fn a() -> Int { 1 }\n}\n".to_string(),
2857        )]);
2858        let first = enumerated_units("context a.b\n", Some(&first_files));
2859        assert!(
2860            first.iter().any(|n| n == "proj.first"),
2861            "first read: {first:?}"
2862        );
2863
2864        // Same path, different content — the cache is keyed on content
2865        // equality (not disk metadata, since content is supplied directly
2866        // and an open-buffer overlay has no meaningful mtime at all), so it
2867        // must serve the new parse, not the stale one.
2868        let second_files = HashMap::from([(
2869            path,
2870            "commons proj.second.longer {\n  fn a() -> Int { 1 }\n  fn c() -> Int { 3 }\n}\n"
2871                .to_string(),
2872        )]);
2873        let second = enumerated_units("context a.b\n", Some(&second_files));
2874        assert!(
2875            second.iter().any(|n| n == "proj.second.longer"),
2876            "after change: {second:?}"
2877        );
2878        assert!(
2879            !second.iter().any(|n| n == "proj.first"),
2880            "stale served: {second:?}"
2881        );
2882    }
2883
2884    #[test]
2885    fn unparseable_project_file_is_skipped() {
2886        // Content-ownership track (#1086) slice 0+1: `cached_project_unit` no
2887        // longer reads disk itself, so there is no more "path doesn't exist on
2888        // disk" case to skip — the equivalent today is content that fails to
2889        // parse (the caller's sweep omits an unreadable file from the map
2890        // entirely, so this covers the other unusable case: present but not
2891        // parseable).
2892        let files = HashMap::from([(synthetic_path("broken"), "not bynk source {{{".to_string())]);
2893        let names = enumerated_units("context a.b\n", Some(&files));
2894        let baseline = enumerated_units("context a.b\n", None);
2895        // No panic, and the broken file contributes nothing beyond the same
2896        // embedded surface + buffer a project with no files at all yields —
2897        // not just "the embedded surface is present somewhere", which would
2898        // pass even if a future recovery-parse change made the broken
2899        // content yield a spurious unit alongside it.
2900        assert_eq!(names, baseline, "{names:?}");
2901    }
2902}