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};
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 whole-second `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/// The parsed unit for a project file's already-read `content`. P8.4
1505/// (#1515): served from `bynk_project::parse_cache`'s own shared, durable
1506/// cache (replacing this module's former `PROJECT_UNIT_CACHE` — one cache
1507/// of the same fact, not two independently-invalidated ones, per ADR
1508/// 0413/[DECISION C]) when the cached/fresh **strict** parse is clean; a
1509/// syntax error elsewhere in the project falls back to a local, uncached
1510/// recovery-parse for that one file ([DECISION E], `bynk-project::
1511/// parse_cache`'s own doc comment) — for clean source (the overwhelming
1512/// common case) the strict and recovery parsers produce the identical AST,
1513/// since there is nothing to recover from.
1514///
1515/// PR #1520's own bot review (finding #4): `parser::
1516/// parse_units_with_warnings_from` (what the shared cache stores) always
1517/// converts an empty unit list into `Err("bynk.parse.unexpected_eof")`
1518/// (`bynk-syntax/src/parser.rs`'s own `parse_units_with_drain_check_from`)
1519/// — `Ok` with zero units is not a real case this parser produces, so
1520/// `units.first()` alone says what is meant without an `is_empty()` guard
1521/// that would otherwise route a (nonexistent) clean-but-empty parse down
1522/// the same uncached recovery-parse fallback as a genuine syntax error,
1523/// permanently, on every request.
1524fn cached_project_unit(path: &Path, content: &str) -> Option<Arc<SourceUnit>> {
1525    let (_file_id, result) = bynk_project::parse_cache::cached_parse(path, content);
1526    match result {
1527        Ok((units, _warnings)) => units.first().cloned().map(Arc::new),
1528        Err(_) => parse_source_unit(content).map(Arc::new),
1529    }
1530}
1531
1532/// Parse every project unit, plus the embedded first-party adapters (the
1533/// `bynk` surface and the `bynk.cloudflare` platform adapter), and call `f`
1534/// for each. Recovery parsing tolerates the in-progress edit at the cursor.
1535///
1536/// The embedded surface is parsed once (`EMBEDDED_UNITS`) and the project's
1537/// other files are served from a per-file parse cache (`cached_project_unit`),
1538/// keyed on content rather than disk metadata (content-ownership track,
1539/// #1086, slice 0+1) — `files` is a pre-read `(path, content)` map the caller
1540/// (`bynk-lsp`) built by overlaying every open buffer over a disk sweep, so
1541/// an unsaved edit to file A is visible here exactly like a saved one. Only
1542/// `doc_text` — the buffer under the cursor, which changes per keystroke — is
1543/// parsed fresh each call (#733), never looked up in `files` (the caller
1544/// excludes the cursor's own file from `files` for exactly this reason).
1545/// Ordering is preserved (embedded, then the buffer, then the other files) so
1546/// a callback's first-name-wins dedup still prefers the live buffer.
1547pub fn for_each_unit(
1548    doc_text: &str,
1549    files: Option<&HashMap<PathBuf, String>>,
1550    mut f: impl FnMut(&SourceUnit),
1551) {
1552    for unit in EMBEDDED_UNITS.iter() {
1553        f(unit);
1554    }
1555    if let Some(unit) = parse_source_unit(doc_text) {
1556        f(&unit);
1557    }
1558    // Slice A: the project's files come from the compiler's own discovery
1559    // (`bynk_ide::discover_files`) — every `include` root, `exclude` honoured.
1560    // This used to walk a single directory by hand, so completion could not see
1561    // a second root and swept `out`/`node_modules` if they sat beneath it.
1562    if let Some(content) = files {
1563        for (path, text) in content {
1564            if let Some(unit) = cached_project_unit(path, text) {
1565                f(&unit);
1566            }
1567        }
1568    }
1569}
1570
1571/// Consumable unit names: contexts and adapters (plus `bynk`), deduplicated.
1572fn consumable_units(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<Completion> {
1573    let mut seen: BTreeSet<String> = BTreeSet::new();
1574    let mut out: Vec<Completion> = Vec::new();
1575    for_each_unit(doc_text, files, |unit| {
1576        let (name, kind) = match unit {
1577            SourceUnit::Context(c) => (c.name.joined(), "context"),
1578            SourceUnit::Adapter(a) => (a.name.joined(), "adapter"),
1579            _ => return,
1580        };
1581        if seen.insert(name.clone()) {
1582            out.push(Completion::item(
1583                name,
1584                CompletionKind::Unit,
1585                Some(kind.to_string()),
1586            ));
1587        }
1588    });
1589    out
1590}
1591
1592/// The capability names a unit `exports capability`.
1593///
1594/// Finding #62: a first-wins guard, for the same buffer-vs-stale-disk-copy
1595/// reason as [`record_field_names`] — the `BTreeSet` still dedups a single
1596/// matching unit's own (possibly repeated) export clauses.
1597fn capabilities_of_unit(
1598    unit: &str,
1599    doc_text: &str,
1600    files: Option<&HashMap<PathBuf, String>>,
1601) -> Vec<String> {
1602    let mut out: BTreeSet<String> = BTreeSet::new();
1603    let mut found = false;
1604    for_each_unit(doc_text, files, |u| {
1605        if found {
1606            return;
1607        }
1608        let (name, exports) = match u {
1609            SourceUnit::Context(c) => (c.name.joined(), &c.exports),
1610            SourceUnit::Adapter(a) => (a.name.joined(), &a.exports),
1611            _ => return,
1612        };
1613        if name != unit {
1614            return;
1615        }
1616        found = true;
1617        for clause in exports {
1618            if clause.kind == ExportKind::Capability {
1619                for n in clause.names.iter().map(|e| &e.name) {
1620                    out.insert(n.name.clone());
1621                }
1622            }
1623        }
1624    });
1625    out.into_iter().collect()
1626}
1627
1628/// Capabilities in scope for a `given` clause in the current document: locally
1629/// declared capabilities, bare names flattened by a braced `consumes`, and
1630/// `U.Cap` for each whole-unit `consumes U`.
1631fn in_scope_capabilities(
1632    doc_text: &str,
1633    files: Option<&HashMap<PathBuf, String>>,
1634) -> Vec<Completion> {
1635    let mut labels: BTreeSet<String> = BTreeSet::new();
1636    let Ok(tokens) = lexer::tokenize(doc_text) else {
1637        return Vec::new();
1638    };
1639    let (Some(unit), _errs) = parser::parse_unit_with_recovery(&tokens, doc_text) else {
1640        return Vec::new();
1641    };
1642    let (items, consumes) = match &unit {
1643        SourceUnit::Context(c) => (&c.items, &c.consumes),
1644        SourceUnit::Adapter(a) => (&a.items, &EMPTY_CONSUMES),
1645        _ => return Vec::new(),
1646    };
1647    // Locally declared capabilities.
1648    for item in items {
1649        if let bynk_syntax::ast::CommonsItem::Capability(c) = item {
1650            labels.insert(c.name.name.clone());
1651        }
1652    }
1653    // Consumed capabilities: flattened bare names, or qualified `U.Cap`.
1654    for c in consumes {
1655        let unit_name = c.target.joined();
1656        match &c.selected {
1657            Some(names) => {
1658                for n in names {
1659                    labels.insert(n.name.clone());
1660                }
1661            }
1662            None => {
1663                let prefix = c
1664                    .alias
1665                    .as_ref()
1666                    .map(|a| a.name.clone())
1667                    .unwrap_or_else(|| unit_name.clone());
1668                for cap in capabilities_of_unit(&unit_name, doc_text, files) {
1669                    labels.insert(format!("{prefix}.{cap}"));
1670                }
1671            }
1672        }
1673    }
1674    labels
1675        .into_iter()
1676        .map(|label| {
1677            Completion::item(
1678                label,
1679                CompletionKind::Capability,
1680                Some("capability in scope".to_string()),
1681            )
1682        })
1683        .collect()
1684}
1685
1686// -- Value-receiver `.method`/`.field` (slice 3, ADR 0063) --
1687
1688/// If the cursor (byte `offset` into `text`) sits just after a **lowercase**
1689/// `receiver.`(`partial`) — a *value* receiver — return the buffer **rewritten**
1690/// so the receiver is a complete expression (the trailing `.partial` dropped,
1691/// so the file parses) and the byte offset of the receiver to type. Returns
1692/// `None` for an uppercase name receiver (slice 2), a decimal `1.`, or a
1693/// `.`-qualified segment.
1694///
1695/// The rewrite is the spike's fix for the mid-edit parse: a bare `email.`
1696/// cascades and loses the receiver, but `email` (dot dropped) types cleanly.
1697pub fn value_receiver_rewrite(text: &str, offset: usize) -> Option<(String, usize)> {
1698    let prefix = text.get(..offset)?;
1699    let head = prefix
1700        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
1701        .strip_suffix('.')?;
1702    let (recv, start) = ident_ending_at(head, head.len())?;
1703    let first = recv.chars().next()?;
1704    if !(first.is_ascii_lowercase() || first == '_') {
1705        return None; // uppercase = name receiver (slice 2); a digit = a decimal
1706    }
1707    if head[..start].ends_with('.') {
1708        return None; // a `.`-qualified segment, not a bare value receiver
1709    }
1710    let dot = head.len(); // the receiver ends here; the dot was the next byte
1711    let rewritten = format!("{}{}", &text[..dot], &text[offset..]);
1712    Some((rewritten, dot.saturating_sub(1)))
1713}
1714
1715/// The identifier run ending at byte `end` in `text` — `(name, start)` — the
1716/// trim-back-to-a-non-identifier-boundary scan shared by every receiver/
1717/// identifier extraction in the LSP: [`value_receiver_rewrite`] above,
1718/// `symbols::receiver_segment_at` (a member's receiver, dot-preceded), and
1719/// `symbols::store_field_kind_at` (a bare receiver's own end offset, no dot).
1720/// Each has different preconditions on what precedes `end`, but the boundary
1721/// scan itself — walk back to the nearest non-identifier char, respecting
1722/// UTF-8 boundaries — is one definition, so it can't drift between them (a
1723/// review flagged the previous three near-identical copies). `None` if
1724/// nothing identifier-shaped precedes `end`.
1725pub fn ident_ending_at(text: &str, end: usize) -> Option<(&str, usize)> {
1726    let before = text.get(..end)?;
1727    let start = before
1728        .char_indices()
1729        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
1730        .map_or(0, |(i, c)| i + c.len_utf8());
1731    let name = &before[start..];
1732    (!name.is_empty()).then_some((name, start))
1733}
1734
1735/// The members of a typed value receiver: the built-in kernel methods of its
1736/// type (from the enumerable registry) plus, for a record, its fields.
1737pub fn value_member_candidates(
1738    ty: TyId,
1739    tys: &Types,
1740    doc_text: &str,
1741    files: Option<&HashMap<PathBuf, String>>,
1742) -> Vec<Completion> {
1743    let mut out: Vec<Completion> = kernel_methods::methods_for(ty, tys)
1744        .iter()
1745        .map(|km| {
1746            Completion::item(
1747                km.name,
1748                CompletionKind::Member,
1749                Some(km.signature.to_string()),
1750            )
1751        })
1752        .collect();
1753    // Record fields — resolve the receiver's named type to its declaration.
1754    if let Ty::Named { name, .. } = &*tys.get(ty) {
1755        let mut seen: BTreeSet<String> = BTreeSet::new();
1756        for_each_unit(doc_text, files, |unit| {
1757            let items = match unit {
1758                SourceUnit::Commons(c) => &c.items,
1759                SourceUnit::Context(c) => &c.items,
1760                SourceUnit::Adapter(a) => &a.items,
1761                _ => return,
1762            };
1763            for item in items {
1764                if let CommonsItem::Type(t) = item
1765                    && &t.name.name == name
1766                    && let TypeBody::Record(r) = &t.body
1767                {
1768                    for f in &r.fields {
1769                        if seen.insert(f.name.name.clone()) {
1770                            out.push(Completion::item(
1771                                f.name.name.clone(),
1772                                CompletionKind::Field,
1773                                Some(format!("field of `{name}`")),
1774                            ));
1775                        }
1776                    }
1777                }
1778            }
1779        });
1780    }
1781    out
1782}
1783
1784/// #596: the entry ops (and, for a `Map`, the `.entries`/`.keys`/`.values`
1785/// query accessors) of a bare `store` field receiver — merged onto
1786/// [`value_member_candidates`] so a store field offers its whole vocabulary,
1787/// not just the `Query` half `kernel_methods::methods_for` covers. A bare
1788/// store `Map` field types (via the checker's ADR 0120 "whole map as a value"
1789/// reading) to plain `Ty::Query`, indistinguishable from an ordinary
1790/// `Query`-typed local — so this reads the receiver's *provenance* instead,
1791/// the same way hover's `describe_store_op_at` does. Empty when the receiver
1792/// isn't a store field of an enclosing agent, or is shadowed by a local.
1793///
1794/// `rewritten`/`recv_offset` are [`value_receiver_rewrite`]'s output; `locals`
1795/// is the current round's locals for the file, best-effort (empty when the
1796/// analysed round doesn't match `rewritten`, in which case the shadowing
1797/// check simply sees no local in scope).
1798pub fn store_field_member_candidates(
1799    rewritten: &str,
1800    recv_offset: usize,
1801    locals: &[LocalBinding],
1802) -> Vec<Completion> {
1803    let Some((kind_head, held)) =
1804        crate::symbols::store_field_kind_at(rewritten, recv_offset + 1, locals)
1805    else {
1806        return Vec::new();
1807    };
1808    let mut out: Vec<Completion> = store_ops::ops_for(&kind_head)
1809        .iter()
1810        .map(|o| {
1811            Completion::item(
1812                o.name,
1813                CompletionKind::Member,
1814                Some(o.signature.to_string()),
1815            )
1816        })
1817        .collect();
1818    if kind_head == "Map" && !held {
1819        out.extend(store_ops::MAP_QUERY_ACCESSORS.iter().map(|a| {
1820            Completion::item(a.name, CompletionKind::Field, Some(a.signature.to_string()))
1821        }));
1822    }
1823    out
1824}
1825
1826static EMPTY_CONSUMES: Vec<bynk_syntax::ast::ConsumesDecl> = Vec::new();
1827
1828#[cfg(test)]
1829mod tests {
1830    use super::*;
1831    use bynk_check::firstparty::BYNK_LIST_SRC;
1832
1833    fn labels(line: &str, doc: &str) -> Vec<String> {
1834        complete(line, doc, None)
1835            .into_iter()
1836            .map(|c| c.label)
1837            .collect()
1838    }
1839
1840    #[test]
1841    fn consumes_target_suggests_units_including_bynk() {
1842        // An adapter in the open doc plus the always-available `bynk` surface.
1843        let doc = "adapter tokens {\n  binding \"./b.ts\"\n  capability Jwt { fn f() -> Effect[Int] }\n  provides Jwt = X\n}\n";
1844        let got = labels("  consumes ", doc);
1845        assert!(got.contains(&"bynk".to_string()), "{got:?}");
1846        assert!(got.contains(&"tokens".to_string()), "{got:?}");
1847    }
1848
1849    #[test]
1850    fn consumes_brace_suggests_that_units_capabilities() {
1851        let got = labels("  consumes bynk { ", "context a.b\n");
1852        // The embedded `bynk` surface exports these.
1853        assert!(got.contains(&"Clock".to_string()), "{got:?}");
1854        assert!(got.contains(&"Random".to_string()), "{got:?}");
1855        assert!(got.contains(&"Logger".to_string()), "{got:?}");
1856    }
1857
1858    #[test]
1859    fn given_suggests_local_and_flattened_capabilities() {
1860        let doc = "context a.b\n\
1861                   consumes bynk { Clock }\n\
1862                   capability Local { fn f() -> Effect[Int] }\n\
1863                   service s {\n\
1864                   on call() -> Effect[Int] given Clock {\n\
1865                   1\n\
1866                   }\n\
1867                   }\n";
1868        let got = labels("    on call() -> Effect[Int] given ", doc);
1869        assert!(got.contains(&"Clock".to_string()), "flattened: {got:?}");
1870        assert!(got.contains(&"Local".to_string()), "local: {got:?}");
1871    }
1872
1873    #[test]
1874    fn expression_position_offers_constructors_and_types() {
1875        // ADR 0093 D3/D5: a value position (after `=`) yields every constructor
1876        // keyword and in-scope type names — the entry to a static call or a
1877        // record construction. (Locals/params are appended handler-side, not by
1878        // `complete()`.) Registry-driven over CONSTRUCTORS.
1879        let doc = "commons m {\n  type Order = { id: Int }\n}\n";
1880        let items = complete("  let x = ", doc, None);
1881        for &c in CONSTRUCTORS {
1882            assert!(
1883                find(&items, c, CompletionKind::Constructor).is_some(),
1884                "constructor {c}: {:?}",
1885                items.iter().map(|i| &i.label).collect::<Vec<_>>()
1886            );
1887        }
1888        assert!(
1889            find(&items, "Int", CompletionKind::Type).is_some(),
1890            "builtin type"
1891        );
1892        assert!(
1893            find(&items, "Order", CompletionKind::Type).is_some(),
1894            "project type"
1895        );
1896    }
1897
1898    #[test]
1899    fn value_receiver_and_decimal_are_not_expression_positions() {
1900        // A trailing `x.`/`1.` is a member/decimal context, not an expression
1901        // start — `complete()` yields nothing (the value-receiver path is
1902        // handler-side; see `record_value_and_decimal_receivers_yield_nothing`).
1903        assert!(complete("  let p = q.", "context a.b\n", None).is_empty());
1904        assert!(complete("  let n = 1.", "context a.b\n", None).is_empty());
1905    }
1906
1907    /// Free `fn` names declared in a unit source (registry-driven test helper).
1908    fn free_fn_names(src: &str) -> Vec<String> {
1909        let tokens = lexer::tokenize(src).unwrap();
1910        let (unit, _) = parser::parse_unit_with_recovery(&tokens, src);
1911        let unit = unit.unwrap();
1912        let (items, _) = unit_items_and_uses(&unit);
1913        items
1914            .iter()
1915            .filter_map(|it| match it {
1916                CommonsItem::Fn(f) => match &f.name {
1917                    FnName::Free(id) => Some(id.name.clone()),
1918                    FnName::Method { .. } => None,
1919                },
1920                _ => None,
1921            })
1922            .collect()
1923    }
1924
1925    #[test]
1926    fn free_functions_offered_for_own_unit_and_used_modules() {
1927        // ADR 0093 D3/G5: expression position offers the current unit's own
1928        // free `fn`s and the combinators of every `uses`-imported module.
1929        let doc = "commons app {\n  uses bynk.list\n  fn helper(x: Int) -> Int { x }\n}\n";
1930        let items = complete("  let y = ", doc, None);
1931        // The current unit's own function.
1932        assert!(
1933            find(&items, "helper", CompletionKind::Function).is_some(),
1934            "own fn: {:?}",
1935            items.iter().map(|i| &i.label).collect::<Vec<_>>()
1936        );
1937        // Every combinator of the imported `bynk.list` — registry-driven over the
1938        // embedded source, so a new stdlib combinator must surface or this fails.
1939        for name in free_fn_names(BYNK_LIST_SRC) {
1940            assert!(
1941                find(&items, &name, CompletionKind::Function).is_some(),
1942                "bynk.list.{name}: {:?}",
1943                items.iter().map(|i| &i.label).collect::<Vec<_>>()
1944            );
1945        }
1946        // A module that is not imported does not leak its fns.
1947        assert!(
1948            find(&items, "values", CompletionKind::Function).is_none(),
1949            "bynk.map.values leaked without `uses bynk.map`"
1950        );
1951    }
1952
1953    #[test]
1954    fn locale_functions_offered_when_bynk_locale_is_used() {
1955        // #901: `bynk.locale` was absent from the embedded-source list, so its
1956        // builders/render never surfaced in completion even under `uses
1957        // bynk.locale`. Exercised through the real `complete()` path, not the
1958        // renderer alone.
1959        let doc = "commons app {\n  uses bynk.locale\n}\n";
1960        let items = complete("  let y = ", doc, None);
1961        for name in [
1962            "render",
1963            "message",
1964            "withText",
1965            "withWhole",
1966            "withNum",
1967            "withMoment",
1968        ] {
1969            assert!(
1970                find(&items, name, CompletionKind::Function).is_some(),
1971                "bynk.locale.{name} missing from completion: {:?}",
1972                items.iter().map(|i| &i.label).collect::<Vec<_>>()
1973            );
1974        }
1975    }
1976
1977    #[test]
1978    fn free_functions_require_a_uses_import() {
1979        // Own fns are always in scope; stdlib combinators only with their `uses`.
1980        let doc = "commons app {\n  fn helper(x: Int) -> Int { x }\n}\n";
1981        let items = complete("  let y = ", doc, None);
1982        assert!(find(&items, "helper", CompletionKind::Function).is_some());
1983        for name in ["map", "filter", "reverse"] {
1984            assert!(
1985                find(&items, name, CompletionKind::Function).is_none(),
1986                "bynk.list.{name} offered without `uses bynk.list`"
1987            );
1988        }
1989    }
1990
1991    #[test]
1992    fn member_completion_reaches_inside_an_interpolation_hole() {
1993        // v0.43: a `Type.`/`Cap.` receiver inside a `\(…)` hole completes just
1994        // as it does in bare expression position — context detection is purely
1995        // lexical, so the surrounding string and `\(` do not interfere.
1996        let doc = "context a.b\n  capability Timer { fn now() -> Effect[Int] }\n";
1997        let in_hole = complete("    \"the time is \\(Timer.", doc, None);
1998        assert!(
1999            find(&in_hole, "now", CompletionKind::Member).is_some(),
2000            "capability op not offered inside a hole: {:?}",
2001            in_hole.iter().map(|c| &c.label).collect::<Vec<_>>()
2002        );
2003        // A built-in static receiver works inside a hole too.
2004        let statics = complete("  \"n=\\(Int.", "context a.b\n", None);
2005        assert!(find(&statics, "parse", CompletionKind::Member).is_some());
2006    }
2007
2008    #[test]
2009    fn consumes_with_as_is_not_a_target_completion() {
2010        // `consumes X as ` is aliasing, not target-name completion.
2011        assert!(!is_consumes_target("consumes platform.time as "));
2012        assert!(is_consumes_target("consumes platform"));
2013    }
2014
2015    fn find<'a>(
2016        items: &'a [Completion],
2017        label: &str,
2018        kind: CompletionKind,
2019    ) -> Option<&'a Completion> {
2020        items.iter().find(|c| c.label == label && c.kind == kind)
2021    }
2022
2023    #[test]
2024    fn type_annotation_suggests_builtins_surface_and_project_types() {
2025        let doc = "commons m {\n  type Order = { id: Int }\n}\n";
2026        let got = labels("  let x: ", doc);
2027        // Built-ins (with registry docs), the `bynk`-surface transparent types,
2028        // and the project's own type declaration.
2029        for want in ["Int", "Option", "Result", "Effect", "List", "Map"] {
2030            assert!(got.contains(&want.to_string()), "built-in {want}: {got:?}");
2031        }
2032        assert!(got.contains(&"Uuid".to_string()), "surface: {got:?}");
2033        assert!(got.contains(&"Order".to_string()), "project: {got:?}");
2034    }
2035
2036    #[test]
2037    fn return_type_and_type_args_are_type_positions() {
2038        assert!(is_type_position("  on call() -> "));
2039        assert!(is_type_position("  let x: Option["));
2040        assert!(is_type_position("  let x: Result[Int, "));
2041        // A partial type name being typed still counts.
2042        assert!(is_type_position("  -> Eff"));
2043    }
2044
2045    #[test]
2046    fn list_literal_is_not_a_type_position() {
2047        // A bare `[` opening a list literal is an expression, not type args…
2048        assert!(!is_type_position("  let xs = ["));
2049        // …so it is an expression position: a list element is a value, and the
2050        // constructor keywords are offered there (ADR 0093 D3) — not a
2051        // type-argument completion.
2052        let items = complete("  let xs = [", "context a.b\n", None);
2053        assert!(
2054            find(&items, "Some", CompletionKind::Constructor).is_some(),
2055            "{:?}",
2056            items.iter().map(|c| &c.label).collect::<Vec<_>>()
2057        );
2058    }
2059
2060    #[test]
2061    fn builtin_type_carries_its_registry_doc() {
2062        let items = complete("  let x: ", "context a.b\n", None);
2063        let int = find(&items, "Int", CompletionKind::Type).expect("Int present");
2064        assert_eq!(int.detail.as_deref(), keyword_doc("Int"));
2065        assert!(int.detail.is_some(), "Int should have a doc");
2066    }
2067
2068    #[test]
2069    fn keyword_position_suggests_keywords_and_snippets() {
2070        let items = complete("  ", "context a.b\n", None);
2071        // Declaration/statement keywords, with docs.
2072        assert!(find(&items, "capability", CompletionKind::Keyword).is_some());
2073        assert!(find(&items, "fn", CompletionKind::Keyword).is_some());
2074        assert!(find(&items, "let", CompletionKind::Keyword).is_some());
2075        // Uppercase type/value names are *not* keyword-position candidates.
2076        assert!(find(&items, "Int", CompletionKind::Keyword).is_none());
2077        assert!(find(&items, "Some", CompletionKind::Keyword).is_none());
2078        // Snippets are offered alongside.
2079        let snip = find(&items, "service", CompletionKind::Snippet).expect("service snippet");
2080        let body = snip.insert_text.as_deref().unwrap_or("");
2081        assert!(body.contains("on call"), "snippet body: {body:?}");
2082        assert!(body.contains("${1"), "snippet tab stop: {body:?}");
2083    }
2084
2085    #[test]
2086    fn keyword_position_fires_on_an_empty_line() {
2087        assert!(is_keyword_position(""));
2088        assert!(is_keyword_position("  cap"));
2089        assert!(!is_keyword_position("  let x ="));
2090        assert!(!is_keyword_position("  x: "));
2091        assert!(!complete("", "context a.b\n", None).is_empty());
2092    }
2093
2094    #[test]
2095    fn member_receiver_is_a_single_upper_ident_before_a_dot() {
2096        assert_eq!(member_receiver("  Color."), Some("Color".to_string()));
2097        assert_eq!(
2098            member_receiver("  let e = Email.o"),
2099            Some("Email".to_string())
2100        );
2101        assert_eq!(member_receiver("  x."), None); // lowercase = value receiver (slice 3)
2102        assert_eq!(member_receiver("  1."), None); // decimal literal, not a member access
2103        assert_eq!(member_receiver("  a.B."), None); // `.`-qualified segment
2104        assert_eq!(member_receiver("  Color"), None); // no dot yet
2105    }
2106
2107    #[test]
2108    fn receiver_extraction_survives_a_multibyte_char_before_the_receiver() {
2109        // A multi-byte non-identifier char immediately before the receiver used
2110        // to make the `i + 1` byte offset land mid-codepoint → panic on slice
2111        // (#715, hit on every completion/hover keystroke inside a string).
2112        assert_eq!(member_receiver("\"Foo."), Some("Foo".to_string()));
2113        assert_eq!(member_receiver("€Color."), Some("Color".to_string()));
2114        assert_eq!(member_receiver("—Bar."), Some("Bar".to_string()));
2115        assert_eq!(word_before_brace("\"cors {", 6), "cors");
2116        assert_eq!(
2117            record_construction_receiver("\"€Order {"),
2118            Some("Order".to_string())
2119        );
2120        // The reproduction from the issue: a completion request against a buffer
2121        // where the line prefix opens a string literal must not panic.
2122        let _ = complete("let x = \"Foo.", "commons m {}\n", None);
2123        let _ = complete("  \"€42.", "commons m {}\n", None);
2124        assert_eq!(
2125            value_receiver_rewrite("\"email.", 7).map(|(_, r)| r),
2126            Some(5),
2127        );
2128    }
2129
2130    #[test]
2131    fn sum_member_suggests_variants() {
2132        let doc = "commons m {\n  type Color = enum { Red, Green, Blue }\n}\n";
2133        let items = complete("  let c = Color.", doc, None);
2134        for v in ["Red", "Green", "Blue"] {
2135            assert!(
2136                find(&items, v, CompletionKind::Variant).is_some(),
2137                "variant {v}: {:?}",
2138                items.iter().map(|c| &c.label).collect::<Vec<_>>()
2139            );
2140        }
2141    }
2142
2143    #[test]
2144    fn refined_and_plain_alias_members_are_of_and_unsafe() {
2145        // A refinement-bearing type…
2146        let doc = "commons m {\n  type Email = String where NonEmpty\n}\n";
2147        let items = complete("  Email.", doc, None);
2148        assert!(find(&items, "of", CompletionKind::Member).is_some());
2149        assert!(find(&items, "unsafe", CompletionKind::Member).is_some());
2150        // …and a plain alias `type Id = Int` is *also* branded (the emitter
2151        // emits Id.of/Id.unsafe for every Refined body, refinement or not).
2152        let doc = "commons m {\n  type Id = Int\n}\n";
2153        assert!(find(&complete("  Id.", doc, None), "of", CompletionKind::Member).is_some());
2154    }
2155
2156    #[test]
2157    fn capability_member_suggests_ops() {
2158        let doc = "context a.b\n  capability Timer { fn now() -> Effect[Int]\n  fn at(t: Int) -> Effect[()] }\n";
2159        let items = complete("    Timer.", doc, None);
2160        let now = find(&items, "now", CompletionKind::Member).expect("`now` op offered");
2161        // Slice 5 detail polish: a typed signature (params + return), not bare
2162        // param names.
2163        assert_eq!(
2164            now.detail.as_deref(),
2165            Some("now() -> Effect[Int] — operation of `Timer`")
2166        );
2167        let at = find(&items, "at", CompletionKind::Member).expect("`at` op offered");
2168        assert_eq!(
2169            at.detail.as_deref(),
2170            Some("at(t: Int) -> Effect[()] — operation of `Timer`")
2171        );
2172    }
2173
2174    #[test]
2175    fn builtin_type_statics_are_offered() {
2176        assert!(
2177            find(
2178                &complete("  Int.", "context a.b\n", None),
2179                "parse",
2180                CompletionKind::Member
2181            )
2182            .is_some()
2183        );
2184        let j = complete("  Json.", "context a.b\n", None);
2185        assert!(find(&j, "encode", CompletionKind::Member).is_some());
2186        assert!(find(&j, "decode", CompletionKind::Member).is_some());
2187    }
2188
2189    #[test]
2190    fn builtin_sum_variants_are_complete() {
2191        // ADR 0093 D5/G3: every built-in sum variant in the AST registry must
2192        // surface on its name receiver. Registry-driven — adding an
2193        // `HttpResult`/`QueueResult` variant must appear in completion or this
2194        // fails (the standing drift guard, mirroring `kernel_registry`).
2195        let http: Vec<&str> = bynk_syntax::ast::HTTP_VARIANTS
2196            .iter()
2197            .map(|v| v.name)
2198            .collect();
2199        let queue: Vec<&str> = bynk_syntax::ast::QUEUE_VARIANTS
2200            .iter()
2201            .map(|v| v.name)
2202            .collect();
2203        for (recv, names) in [("HttpResult", http), ("QueueResult", queue)] {
2204            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2205            for name in names {
2206                assert!(
2207                    find(&items, name, CompletionKind::Variant).is_some(),
2208                    "{recv}.{name} missing: {:?}",
2209                    items.iter().map(|c| &c.label).collect::<Vec<_>>()
2210                );
2211            }
2212        }
2213    }
2214
2215    #[test]
2216    fn builtin_statics_are_reachable() {
2217        // ADR 0093 D5/G2: every BUILTIN_STATICS entry is reachable through the
2218        // name-receiver context — exercises the member_receiver→member_candidates
2219        // wiring for each receiver (e.g. that `Effect.`/`List.` are recognised).
2220        for &(recv, members) in BUILTIN_STATICS {
2221            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2222            for &(member, _) in members {
2223                assert!(
2224                    find(&items, member, CompletionKind::Member).is_some(),
2225                    "{recv}.{member} unreachable: {:?}",
2226                    items.iter().map(|c| &c.label).collect::<Vec<_>>()
2227                );
2228            }
2229        }
2230        // The slice-1 additions specifically — guards against a table regression
2231        // (the loop above can't catch an entry being deleted).
2232        for (recv, member) in [("List", "empty"), ("Map", "empty"), ("Effect", "pure")] {
2233            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2234            assert!(
2235                find(&items, member, CompletionKind::Member).is_some(),
2236                "{recv}.{member} missing from the statics table"
2237            );
2238        }
2239    }
2240
2241    #[test]
2242    fn record_value_and_decimal_receivers_yield_nothing() {
2243        // A record type has no name-receiver members (fields are value-receiver).
2244        let doc = "commons m {\n  type Point = { x: Int }\n}\n";
2245        assert!(complete("  Point.", doc, None).is_empty(), "record");
2246        // A lowercase value receiver is deferred to slice 3.
2247        assert!(complete("  let p = q.", doc, None).is_empty(), "value");
2248        // A decimal literal is not a member access.
2249        assert!(complete("  let n = 1.", doc, None).is_empty(), "decimal");
2250    }
2251
2252    #[test]
2253    fn value_receiver_rewrite_drops_the_dot_for_lowercase_receivers() {
2254        let text = "  let x = email.\n";
2255        let offset = text.find('.').unwrap() + 1; // just after the dot
2256        let (rewritten, recv) = value_receiver_rewrite(text, offset).expect("value receiver");
2257        assert_eq!(
2258            rewritten, "  let x = email\n",
2259            "the trailing dot is dropped"
2260        );
2261        assert!(
2262            text.get(recv..=recv).is_some_and(|c| c == "l"),
2263            "the receiver offset lands inside `email`"
2264        );
2265        // A partial member is dropped too.
2266        let text2 = "  let x = email.ma\n";
2267        let off2 = text2.find(".ma").unwrap() + 3;
2268        assert_eq!(
2269            value_receiver_rewrite(text2, off2).map(|(r, _)| r),
2270            Some("  let x = email\n".to_string())
2271        );
2272        // Uppercase (name receiver, slice 2), decimal, and no-dot yield None.
2273        assert!(value_receiver_rewrite("  Email.", 8).is_none());
2274        assert!(value_receiver_rewrite("  let n = 1.", 12).is_none());
2275        assert!(value_receiver_rewrite("  email", 7).is_none());
2276    }
2277
2278    /// One table for every fixture in this module — `Types` is `Send + Sync`
2279    /// (T3.6b), so a `LazyLock` static gives the `TyId`s below something to
2280    /// resolve against without threading a value through each helper.
2281    static TYS: std::sync::LazyLock<Types> = std::sync::LazyLock::new(Types::new);
2282
2283    #[test]
2284    fn value_member_candidates_lists_kernel_methods() {
2285        use bynk_syntax::ast::BaseType;
2286        let list = TYS.intern(Ty::List(TYS.intern(Ty::Base(BaseType::Int))));
2287        let items = value_member_candidates(list, &TYS, "context a.b\n", None);
2288        assert!(find(&items, "fold", CompletionKind::Member).is_some());
2289        assert!(find(&items, "get", CompletionKind::Member).is_some());
2290
2291        let string = TYS.intern(Ty::Base(BaseType::String));
2292        let items = value_member_candidates(string, &TYS, "context a.b\n", None);
2293        assert!(find(&items, "split", CompletionKind::Member).is_some());
2294        assert!(find(&items, "trim", CompletionKind::Member).is_some());
2295    }
2296
2297    #[test]
2298    fn value_member_candidates_lists_refined_inherited_kernel_methods() {
2299        // #561: a refined receiver offers its base type's read-only kernel
2300        // methods in `.`-member completion.
2301        use bynk_check::checker::NamedKind;
2302        use bynk_syntax::ast::BaseType;
2303        let name = TYS.intern(Ty::Named {
2304            name: "Name".to_string(),
2305            kind: NamedKind::Refined(BaseType::String),
2306            args: Vec::new(),
2307        });
2308        let items = value_member_candidates(
2309            name,
2310            &TYS,
2311            "commons m {\n  type Name = String where NonEmpty\n}\n",
2312            None,
2313        );
2314        assert!(find(&items, "toUpper", CompletionKind::Member).is_some());
2315        assert!(find(&items, "length", CompletionKind::Member).is_some());
2316    }
2317
2318    #[test]
2319    fn expression_position_offers_locals() {
2320        // Value-expecting positions (locals offered).
2321        assert!(is_expression_position("  let y = "));
2322        assert!(is_expression_position("  let y = a + lo")); // after a binary op
2323        assert!(is_expression_position("  f("));
2324        assert!(is_expression_position("  g(a, "));
2325        assert!(is_expression_position("  xs.fold(0, (acc, x) => ac")); // lambda body
2326        // `let y = foo` is still a value position (you're typing the value).
2327        assert!(is_expression_position("  let y = foo"));
2328        // Not value positions.
2329        assert!(!is_expression_position("  let y: ")); // type annotation
2330        assert!(!is_expression_position("  on call() -> ")); // return type
2331        assert!(!is_expression_position("  tot")); // bare line start (keyword position covers it)
2332    }
2333
2334    #[test]
2335    fn value_member_candidates_lists_record_fields() {
2336        use bynk_check::checker::NamedKind;
2337        let order = TYS.intern(Ty::Named {
2338            name: "Order".to_string(),
2339            kind: NamedKind::Record,
2340            args: Vec::new(),
2341        });
2342        let doc = "commons m {\n  type Order = { id: Int, total: Int }\n}\n";
2343        let items = value_member_candidates(order, &TYS, doc, None);
2344        assert!(
2345            find(&items, "id", CompletionKind::Field).is_some(),
2346            "{items:?}",
2347            items = items.iter().map(|c| &c.label).collect::<Vec<_>>()
2348        );
2349        assert!(find(&items, "total", CompletionKind::Field).is_some());
2350    }
2351
2352    #[test]
2353    fn store_field_member_candidates_offers_entry_ops_and_query_accessors() {
2354        // #596: a bare `store Map` field receiver offers its entry ops
2355        // (put/get/…) AND the `.entries`/`.keys`/`.values` accessors — the
2356        // vocabulary `value_member_candidates` alone can't see, since the
2357        // checker types a bare store map as plain `Ty::Query` (ADR 0120).
2358        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";
2359        let offset = doc.find("items.").unwrap() + "items.".len();
2360        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2361        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2362        assert!(
2363            find(&items, "put", CompletionKind::Member).is_some(),
2364            "{items:?}",
2365            items = items.iter().map(|c| &c.label).collect::<Vec<_>>()
2366        );
2367        assert!(find(&items, "get", CompletionKind::Member).is_some());
2368        assert!(find(&items, "update", CompletionKind::Member).is_some());
2369        assert!(find(&items, "entries", CompletionKind::Field).is_some());
2370        assert!(find(&items, "keys", CompletionKind::Field).is_some());
2371        assert!(find(&items, "values", CompletionKind::Field).is_some());
2372    }
2373
2374    #[test]
2375    fn store_field_member_candidates_empty_for_ordinary_local() {
2376        // An ordinary local named the same as no store field yields nothing —
2377        // the receiver's provenance, not its name alone, drives this path.
2378        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";
2379        let offset = doc.rfind("items.").unwrap() + "items.".len();
2380        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2381        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2382        assert!(items.is_empty());
2383    }
2384
2385    #[test]
2386    fn store_field_member_candidates_skips_query_accessors_on_held_map() {
2387        // ADR 0184: a held `Map[K, Connection]` never offers the key-query
2388        // accessors, only its entry ops (`put`/`get`/…).
2389        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";
2390        let offset = doc.find("conns.").unwrap() + "conns.".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!(find(&items, "put", CompletionKind::Member).is_some());
2394        assert!(find(&items, "entries", CompletionKind::Field).is_none());
2395        assert!(find(&items, "keys", CompletionKind::Field).is_none());
2396        assert!(find(&items, "values", CompletionKind::Field).is_none());
2397    }
2398
2399    #[test]
2400    fn store_field_member_candidates_offers_set_and_cache_vocabularies() {
2401        // Not every store kind gets the Map-only accessors, but every kind
2402        // gets its own entry ops.
2403        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";
2404        let offset = doc.find("tags.").unwrap() + "tags.".len();
2405        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
2406        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
2407        assert!(find(&items, "add", CompletionKind::Member).is_some());
2408        assert!(find(&items, "entries", CompletionKind::Field).is_none());
2409    }
2410
2411    // -- v0.124 (slice 3): the non-keyword completion contexts --
2412
2413    #[test]
2414    fn record_construction_offers_field_names() {
2415        let doc = "commons m {\n  type Order = { id: Int, total: Int }\n}\n";
2416        let got = labels("  let o = Order { ", doc);
2417        assert!(got.contains(&"id".to_string()), "{got:?}");
2418        assert!(got.contains(&"total".to_string()), "{got:?}");
2419        // After a comma, still field-name position.
2420        let got2 = labels("  let o = Order { id: 1, ", doc);
2421        assert!(got2.contains(&"total".to_string()), "{got2:?}");
2422        // After a `:`, it is a field *type* position, not a field name.
2423        assert!(record_construction_receiver("  let o = Order { id: ").is_none());
2424        // A lowercase brace context (a block) is not a construction.
2425        assert!(record_construction_receiver("  if x { ").is_none());
2426    }
2427
2428    #[test]
2429    fn from_offers_protocols() {
2430        let got = labels("  service s from ", "context a.b\n");
2431        assert!(got.contains(&"http".to_string()), "{got:?}");
2432        assert!(got.contains(&"cron".to_string()) && got.contains(&"queue".to_string()));
2433    }
2434
2435    #[test]
2436    fn on_offers_handler_kinds() {
2437        let got = labels("  on ", "context a.b\n");
2438        assert!(got.contains(&"call".to_string()), "{got:?}");
2439        assert!(got.contains(&"GET".to_string()) && got.contains(&"schedule".to_string()));
2440    }
2441
2442    #[test]
2443    fn by_offers_project_actors() {
2444        let doc = "context a.b\n\nactor Caller { auth = Bearer }\n";
2445        let got = labels("    by ", doc);
2446        assert!(got.contains(&"Caller".to_string()), "{got:?}");
2447    }
2448
2449    #[test]
2450    fn exports_offers_export_kinds() {
2451        let got = labels("  exports ", "adapter t {\n  binding \"./b.ts\"\n}\n");
2452        assert!(got.contains(&"capability".to_string()), "{got:?}");
2453        assert!(got.contains(&"transparent".to_string()));
2454    }
2455
2456    #[test]
2457    fn provides_offers_in_scope_capabilities() {
2458        let doc = "context a.b\n\ncapability Store { fn get() -> Effect[Int] }\n";
2459        let got = labels("  provides ", doc);
2460        assert!(got.contains(&"Store".to_string()), "{got:?}");
2461    }
2462
2463    #[test]
2464    fn where_offers_predicate_names() {
2465        // Type-decl refinement.
2466        let got = labels("  type Code = Int where ", "context a.b\n");
2467        assert!(got.contains(&"InRange".to_string()), "{got:?}");
2468        assert!(got.contains(&"NonNegative".to_string()));
2469        // #472: a match arm's `_ where <predicate>` shares the same catalogue.
2470        let got = labels("      _ where ", "context a.b\n");
2471        assert!(got.contains(&"Matches".to_string()), "{got:?}");
2472        assert!(got.contains(&"NonEmpty".to_string()));
2473    }
2474
2475    #[test]
2476    fn is_for_all_where_matches_only_the_for_all_binder() {
2477        assert!(is_for_all_where("\tfor all x: Int, y: Int where "));
2478        assert!(is_for_all_where("for all x: Int where "));
2479        // `for`/`all` must be standalone words, not prefixes of longer
2480        // identifiers.
2481        assert!(!is_for_all_where("format(x) where "));
2482        assert!(!is_for_all_where("for allocate x where "));
2483        // A type-decl or match-arm `where` isn't a `for all` binder.
2484        assert!(!is_for_all_where("  type Code = Int where "));
2485        assert!(!is_for_all_where("      _ where "));
2486        assert!(!is_for_all_where("no where here"));
2487    }
2488
2489    #[test]
2490    fn for_all_where_falls_through_to_expression_position_not_predicates() {
2491        // #472 (finding 2, PR #827 review): a `for all` binder's `where`
2492        // clause takes an arbitrary `Bool`, not the predicate catalogue —
2493        // it must not offer `InRange`/`NonNegative`/etc.
2494        let got = labels("\tfor all x: Int where ", "context a.b\n");
2495        assert!(!got.contains(&"InRange".to_string()), "{got:?}");
2496        assert!(!got.contains(&"NonNegative".to_string()), "{got:?}");
2497    }
2498
2499    #[test]
2500    fn clause_detectors_do_not_over_fire() {
2501        // `on` inside a larger word, and a field named `from`, must not trigger.
2502        assert!(!after_clause_keyword("  session ", "on"));
2503        assert!(!after_clause_keyword("  let from = ", "from"));
2504        // A standalone keyword does.
2505        assert!(after_clause_keyword("  service s from ", "from"));
2506        assert!(after_clause_keyword("    by ", "by"));
2507    }
2508
2509    #[test]
2510    fn contract_clause_kind_detects_requires_and_ensures() {
2511        assert_eq!(contract_clause_kind("  requires positive: "), Some(false));
2512        assert_eq!(contract_clause_kind("  ensures never_neg: "), Some(true));
2513        // Not a contract clause.
2514        assert_eq!(contract_clause_kind("  id: Int"), None);
2515        assert_eq!(contract_clause_kind("  let x = 1"), None);
2516    }
2517
2518    #[test]
2519    fn cors_field_position_inside_cors_block() {
2520        // Cursor at a fresh field-name line inside a `cors { }` block.
2521        let doc = "service api from http {\n  cors {\n    ";
2522        assert!(in_cors_field_position(doc, doc.len()));
2523        // After a `:` it is a value position, not a field-name position.
2524        let doc2 = "service api from http {\n  cors {\n    origins: ";
2525        assert!(!in_cors_field_position(doc2, doc2.len()));
2526        // Not inside a `cors` block (an ordinary record construction) — no.
2527        let doc3 = "let x = Order {\n    ";
2528        assert!(!in_cors_field_position(doc3, doc3.len()));
2529    }
2530
2531    #[test]
2532    fn security_field_position_inside_security_block() {
2533        // Cursor at a fresh field-name line inside a `security { }` block.
2534        let doc = "service api from http {\n  security {\n    ";
2535        assert!(in_security_field_position(doc, doc.len()));
2536        // After a `:` it is a value position, not a field-name position.
2537        let doc2 = "service api from http {\n  security {\n    hsts: ";
2538        assert!(!in_security_field_position(doc2, doc2.len()));
2539        // A `cors { }` block is not a `security` block.
2540        let doc3 = "service api from http {\n  cors {\n    ";
2541        assert!(!in_security_field_position(doc3, doc3.len()));
2542    }
2543
2544    /// Non-ASCII text inside the block (a `-- café` comment) must not break
2545    /// the field-position probes; a mid-codepoint offset degrades to `false`
2546    /// instead of panicking the request handler.
2547    #[test]
2548    fn field_position_probes_survive_non_ascii() {
2549        let doc = "service api from http {\n  cors { -- café\n    ";
2550        assert!(in_cors_field_position(doc, doc.len()));
2551        // A raw byte offset that lands inside the `é` must not panic.
2552        let mid = doc.find('é').unwrap() + 1;
2553        assert!(!doc.is_char_boundary(mid));
2554        let _ = in_cors_field_position(doc, mid);
2555        let _ = in_security_field_position(doc, mid);
2556        let _ = in_limits_field_position(doc, mid);
2557    }
2558
2559    #[test]
2560    fn limits_field_position_inside_limits_block() {
2561        // Cursor at a fresh field-name line inside a `limits { }` block.
2562        let doc = "service api from http {\n  limits {\n    ";
2563        assert!(in_limits_field_position(doc, doc.len()));
2564        // After a `:` it is a value position, not a field-name position.
2565        let doc2 = "service api from http {\n  limits {\n    maxBody: ";
2566        assert!(!in_limits_field_position(doc2, doc2.len()));
2567        // A `security { }` block is not a `limits` block.
2568        let doc3 = "service api from http {\n  security {\n    ";
2569        assert!(!in_limits_field_position(doc3, doc3.len()));
2570    }
2571
2572    #[test]
2573    fn service_body_item_position_offers_cors() {
2574        // A bare-word item start inside a `service … {` block.
2575        let doc = "service api from http {\n  ";
2576        assert!(in_service_body_item_position(doc, doc.len(), "  "));
2577        // Inside a nested `cors { }` block the innermost brace is not the
2578        // service brace, so the service-item cell does not fire.
2579        let doc2 = "service api from http {\n  cors {\n    ";
2580        assert!(!in_service_body_item_position(doc2, doc2.len(), "    "));
2581    }
2582
2583    #[test]
2584    fn cache_arg_position_inside_cache_annotation() {
2585        // Cursor at a fresh argument-name position inside `@cache( … )`.
2586        let doc = "service api from http {\n  @cache(";
2587        assert!(in_cache_arg_position(doc, doc.len()));
2588        // After a first arg + comma, still an argument-name position.
2589        let doc2 = "service api from http {\n  @cache(maxAge: 5.minutes, ";
2590        assert!(in_cache_arg_position(doc2, doc2.len()));
2591        // After a `:` it is a value position, not an argument-name position.
2592        let doc3 = "service api from http {\n  @cache(maxAge: ";
2593        assert!(!in_cache_arg_position(doc3, doc3.len()));
2594        // A bare `cache(` call (no `@`) is not the annotation.
2595        let doc4 = "let x = cache(";
2596        assert!(!in_cache_arg_position(doc4, doc4.len()));
2597        // An ordinary handler param list is not a `@cache` position.
2598        let doc5 = "on GET(\"/x\") by v: Visitor (";
2599        assert!(!in_cache_arg_position(doc5, doc5.len()));
2600    }
2601
2602    #[test]
2603    fn limit_arg_position_inside_limit_annotation() {
2604        // Cursor at a fresh argument-name position inside `@limit( … )`.
2605        let doc = "service api from http {\n  @limit(";
2606        assert!(in_limit_arg_position(doc, doc.len()));
2607        // After a first arg + comma, still an argument-name position.
2608        let doc2 = "service api from http {\n  @limit(maxBody: 1048576, ";
2609        assert!(in_limit_arg_position(doc2, doc2.len()));
2610        // After a `:` it is a value position, not an argument-name position.
2611        let doc3 = "service api from http {\n  @limit(maxBody: ";
2612        assert!(!in_limit_arg_position(doc3, doc3.len()));
2613        // A bare `limit(` call (no `@`) is not the annotation.
2614        let doc4 = "let x = limit(";
2615        assert!(!in_limit_arg_position(doc4, doc4.len()));
2616        // An ordinary handler param list is not a `@limit` position.
2617        let doc5 = "on GET(\"/x\") by v: Visitor (";
2618        assert!(!in_limit_arg_position(doc5, doc5.len()));
2619    }
2620
2621    #[test]
2622    fn sum_type_variants_lists_variants() {
2623        let doc = "commons m {\n  type Status = enum { Pending, Shipped }\n}\n";
2624        let got: Vec<String> = sum_type_variants("Status", doc, None)
2625            .into_iter()
2626            .map(|c| c.label)
2627            .collect();
2628        assert!(got.contains(&"Pending".to_string()), "{got:?}");
2629        assert!(got.contains(&"Shipped".to_string()), "{got:?}");
2630    }
2631
2632    #[test]
2633    fn variants_for_ty_offers_built_in_result_and_option() {
2634        // v0.145 (ADR 0169, base gap): a `Result`/`Option` scrutinee offers its
2635        // built-in variants, which are not declared types (`sum_type_variants`
2636        // can't see them). This is what makes match-arm completion fire for a
2637        // Result/Option scrutinee at all.
2638        use bynk_syntax::ast::BaseType;
2639        let result = TYS.intern(Ty::Result(
2640            TYS.intern(Ty::Base(BaseType::Int)),
2641            TYS.intern(Ty::Base(BaseType::String)),
2642        ));
2643        let got: Vec<String> = variants_for_ty(result, &TYS, "", None)
2644            .into_iter()
2645            .map(|c| c.label)
2646            .collect();
2647        assert_eq!(got, vec!["Ok".to_string(), "Err".to_string()]);
2648
2649        let option = TYS.intern(Ty::Option(TYS.intern(Ty::Base(BaseType::Int))));
2650        let got: Vec<String> = variants_for_ty(option, &TYS, "", None)
2651            .into_iter()
2652            .map(|c| c.label)
2653            .collect();
2654        assert_eq!(got, vec!["Some".to_string(), "None".to_string()]);
2655    }
2656
2657    #[test]
2658    fn nested_variant_completions_resolves_the_payload_type() {
2659        use bynk_check::checker::NamedKind;
2660        use bynk_syntax::ast::BaseType;
2661        // Built-in outer: `Some(‸)` on `Option[Result[Int, E]]` offers the
2662        // payload `Result`'s variants.
2663        let payload = TYS.intern(Ty::Result(
2664            TYS.intern(Ty::Base(BaseType::Int)),
2665            TYS.intern(Ty::Named {
2666                name: "E".to_string(),
2667                kind: NamedKind::Sum,
2668                args: Vec::new(),
2669            }),
2670        ));
2671        let scrut = TYS.intern(Ty::Option(payload));
2672        let got: Vec<String> = nested_variant_completions(scrut, &TYS, "Some", "", None)
2673            .into_iter()
2674            .map(|c| c.label)
2675            .collect();
2676        assert_eq!(got, vec!["Ok".to_string(), "Err".to_string()]);
2677
2678        // The other outer variant of a Result payload resolves the error arm.
2679        let opt_int = TYS.intern(Ty::Option(TYS.intern(Ty::Base(BaseType::Int))));
2680        let inner = TYS.intern(Ty::Result(TYS.intern(Ty::Base(BaseType::Int)), opt_int));
2681        let got: Vec<String> = nested_variant_completions(inner, &TYS, "Err", "", None)
2682            .into_iter()
2683            .map(|c| c.label)
2684            .collect();
2685        assert_eq!(got, vec!["Some".to_string(), "None".to_string()]);
2686
2687        // User-sum outer: `Wrap(‸)` on a declared sum offers the payload field
2688        // type's variants, walked from source.
2689        let doc = "commons m {\n  type Inner = enum { A, B }\n  \
2690                   type Outer = | Wrap(inner: Inner) | Bare\n}\n";
2691        let outer = TYS.intern(Ty::Named {
2692            name: "Outer".to_string(),
2693            kind: NamedKind::Sum,
2694            args: Vec::new(),
2695        });
2696        let got: Vec<String> = nested_variant_completions(outer, &TYS, "Wrap", doc, None)
2697            .into_iter()
2698            .map(|c| c.label)
2699            .collect();
2700        assert!(got.contains(&"A".to_string()), "{got:?}");
2701        assert!(got.contains(&"B".to_string()), "{got:?}");
2702    }
2703
2704    #[test]
2705    fn literal_kind_scrutinee_suggests_no_variants() {
2706        // v0.130: a literal-kind `match` (a primitive `Int`/`String`/`Bool`
2707        // scrutinee, or a refinement over one) has no variant names to offer.
2708        // A primitive types to `Ty::Base`, which never reaches this lookup; a
2709        // refined scrutinee types to `Ty::Named` but has no `enum` body, so the
2710        // variant candidate set is empty — no bogus completions.
2711        let doc = "commons m {\n  type Quantity = Int where InRange(1, 99)\n}\n";
2712        assert!(sum_type_variants("Quantity", doc, None).is_empty());
2713        assert!(sum_type_variants("Int", doc, None).is_empty());
2714    }
2715
2716    // -- parse cache (#733) --
2717
2718    /// Collect the enumerated unit names for a buffer + project file set.
2719    fn enumerated_units(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<String> {
2720        let mut names = Vec::new();
2721        for_each_unit(doc_text, files, |u| names.push(u.name().joined()));
2722        names
2723    }
2724
2725    /// Content-ownership track (#1086) slice 0+1: `files` is now a pre-read
2726    /// `(path, content)` map — the caller's job (`bynk-lsp`'s overlay-then-disk
2727    /// sweep) — rather than a bare path list `cached_project_unit` used to
2728    /// read from disk itself. These tests build that map directly; none of
2729    /// them touch the real filesystem any more.
2730    fn synthetic_path(name: &str) -> PathBuf {
2731        PathBuf::from(format!("/synthetic/{name}.bynk"))
2732    }
2733
2734    #[test]
2735    fn for_each_unit_yields_embedded_buffer_and_project_files() {
2736        let sibling = synthetic_path("sibling");
2737        let files = HashMap::from([(
2738            sibling,
2739            "commons proj.sibling {\n  fn s() -> Int { 1 }\n}\n".to_string(),
2740        )]);
2741
2742        let names = enumerated_units(
2743            "commons proj.buffer {\n  fn b() -> Int { 1 }\n}\n",
2744            Some(&files),
2745        );
2746        // The embedded `bynk` surface, the live buffer, and the project file all show.
2747        assert!(names.iter().any(|n| n == "bynk"), "embedded: {names:?}");
2748        assert!(
2749            names.iter().any(|n| n == "proj.buffer"),
2750            "buffer: {names:?}"
2751        );
2752        assert!(
2753            names.iter().any(|n| n == "proj.sibling"),
2754            "project file: {names:?}"
2755        );
2756    }
2757
2758    /// Finding #62: when the buffer and a project file both declare a type
2759    /// under the same name — the buffer's stale copy, or two distinct files
2760    /// that happen to collide — `record_field_names` and `sum_type_variants`
2761    /// must take the first match (the buffer, since `for_each_unit` yields it
2762    /// before `files`) rather than unioning both, so a field/variant removed
2763    /// from the live buffer does not resurface from the stale copy.
2764    #[test]
2765    fn same_named_declarations_do_not_union_across_units() {
2766        let stale = HashMap::from([(
2767            synthetic_path("stale"),
2768            "commons m {\n  \
2769             type Rec = { total: Int, old_field: Int }\n  \
2770             type Status = enum { Pending, Retired }\n\
2771             }\n"
2772            .to_string(),
2773        )]);
2774
2775        let buffer = "commons m {\n  \
2776                       type Rec = { total: Int }\n  \
2777                       type Status = enum { Pending, Shipped }\n\
2778                       }\n";
2779        let files = Some(&stale);
2780
2781        let fields: Vec<String> = record_field_names("Rec", buffer, files)
2782            .into_iter()
2783            .map(|c| c.label)
2784            .collect();
2785        assert_eq!(fields, vec!["total".to_string()], "{fields:?}");
2786
2787        let variants: Vec<String> = sum_type_variants("Status", buffer, files)
2788            .into_iter()
2789            .map(|c| c.label)
2790            .collect();
2791        assert_eq!(
2792            variants,
2793            vec!["Pending".to_string(), "Shipped".to_string()],
2794            "{variants:?}"
2795        );
2796    }
2797
2798    /// Finding #62: `capabilities_of_unit` takes the first unit named `unit`
2799    /// rather than unioning exports across every matching unit — the same
2800    /// buffer-vs-stale-copy reasoning as
2801    /// `same_named_declarations_do_not_union_across_units`.
2802    #[test]
2803    fn capabilities_of_unit_does_not_union_across_units() {
2804        let stale = HashMap::from([(
2805            synthetic_path("stale"),
2806            "adapter tokens {\n  \
2807             exports capability { Jwt, Retired }\n\
2808             }\n"
2809            .to_string(),
2810        )]);
2811
2812        let buffer = "adapter tokens {\n  exports capability { Jwt }\n}\n";
2813        let files = Some(&stale);
2814
2815        let caps = capabilities_of_unit("tokens", buffer, files);
2816        assert_eq!(caps, vec!["Jwt".to_string()], "{caps:?}");
2817    }
2818
2819    #[test]
2820    fn project_unit_cache_invalidates_on_change() {
2821        let path = synthetic_path("unit");
2822
2823        let first_files = HashMap::from([(
2824            path.clone(),
2825            "commons proj.first {\n  fn a() -> Int { 1 }\n}\n".to_string(),
2826        )]);
2827        let first = enumerated_units("context a.b\n", Some(&first_files));
2828        assert!(
2829            first.iter().any(|n| n == "proj.first"),
2830            "first read: {first:?}"
2831        );
2832
2833        // Same path, different content — the cache is keyed on content
2834        // equality (not disk metadata, since content is supplied directly
2835        // and an open-buffer overlay has no meaningful mtime at all), so it
2836        // must serve the new parse, not the stale one.
2837        let second_files = HashMap::from([(
2838            path,
2839            "commons proj.second.longer {\n  fn a() -> Int { 1 }\n  fn c() -> Int { 3 }\n}\n"
2840                .to_string(),
2841        )]);
2842        let second = enumerated_units("context a.b\n", Some(&second_files));
2843        assert!(
2844            second.iter().any(|n| n == "proj.second.longer"),
2845            "after change: {second:?}"
2846        );
2847        assert!(
2848            !second.iter().any(|n| n == "proj.first"),
2849            "stale served: {second:?}"
2850        );
2851    }
2852
2853    #[test]
2854    fn unparseable_project_file_is_skipped() {
2855        // Content-ownership track (#1086) slice 0+1: `cached_project_unit` no
2856        // longer reads disk itself, so there is no more "path doesn't exist on
2857        // disk" case to skip — the equivalent today is content that fails to
2858        // parse (the caller's sweep omits an unreadable file from the map
2859        // entirely, so this covers the other unusable case: present but not
2860        // parseable).
2861        let files = HashMap::from([(synthetic_path("broken"), "not bynk source {{{".to_string())]);
2862        let names = enumerated_units("context a.b\n", Some(&files));
2863        let baseline = enumerated_units("context a.b\n", None);
2864        // No panic, and the broken file contributes nothing beyond the same
2865        // embedded surface + buffer a project with no files at all yields —
2866        // not just "the embedded surface is present somewhere", which would
2867        // pass even if a future recovery-parse change made the broken
2868        // content yield a spurious unit alongside it.
2869        assert_eq!(names, baseline, "{names:?}");
2870    }
2871}