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::borrow::Cow;
41use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::{Path, PathBuf};
43use std::sync::{Arc, LazyLock};
44
45use bynk_check::checker::{NamedKind, Ty, TyId, Types};
46use bynk_check::kernel_methods;
47use bynk_check::locals::LocalBinding;
48use bynk_check::store_ops;
49use bynk_syntax::ast::{CommonsItem, ExportKind, FnName, SourceUnit, TypeBody, TypeRef, UsesDecl};
50use bynk_syntax::{keywords, lexer, parser};
51
52use crate::symbols::type_ref_str;
53
54/// What a candidate refers to — maps to an LSP `CompletionItemKind`.
55#[derive(Clone, Copy, PartialEq, Eq)]
56pub enum CompletionKind {
57    Unit,
58    Capability,
59    Type,
60    Keyword,
61    Snippet,
62    /// A sum-type variant (`Color.Red`).
63    Variant,
64    /// A name-receiver member: a refined/opaque `of`/`unsafe` constructor, a
65    /// capability operation, or a built-in type static (`Int.parse`).
66    Member,
67    /// A record field on a value receiver (`order.total`).
68    Field,
69    /// A value constructor at expression position (`Ok`/`Some`/`true`).
70    Constructor,
71    /// A free function in scope at expression position — the current unit's own
72    /// top-level `fn`s and the `uses`-imported stdlib/project combinators.
73    Function,
74    /// A named constant value at expression position — an `asset` (#1883),
75    /// in scope by the same rule as a free function.
76    Constant,
77}
78
79pub struct Completion {
80    pub label: String,
81    pub kind: CompletionKind,
82    pub detail: Option<String>,
83    /// LSP snippet text (with `${n:…}`/`$0` tab stops) for `Snippet` items;
84    /// `None` means insert the label verbatim.
85    pub insert_text: Option<String>,
86    /// #1888: the candidate's doc, as Markdown: a record field's or a
87    /// variant's own doc, in either form.
88    pub documentation: Option<String>,
89}
90
91impl Completion {
92    pub fn item(label: impl Into<String>, kind: CompletionKind, detail: Option<String>) -> Self {
93        Completion {
94            label: label.into(),
95            kind,
96            detail,
97            insert_text: None,
98            documentation: None,
99        }
100    }
101
102    /// #1888: this candidate with `doc` as its documentation.
103    fn with_doc(mut self, doc: Option<&str>) -> Self {
104        self.documentation = doc.map(str::to_owned);
105        self
106    }
107
108    fn snippet(label: &str, body: &str) -> Self {
109        Completion {
110            label: label.to_string(),
111            kind: CompletionKind::Snippet,
112            detail: Some(format!("{label} scaffold")),
113            insert_text: Some(body.to_string()),
114            documentation: None,
115        }
116    }
117}
118
119/// Produce completions for the cursor, given the text of the line up to the
120/// cursor, the current document text, and the project source root (if any).
121/// Without a project root, a project's dependencies are found from the
122/// manifest in `files` ([`crate::packages::DependencyView::from_files`]);
123/// the language server calls [`complete_in`] with the root it knows.
124pub fn complete(
125    line_prefix: &str,
126    doc_text: &str,
127    files: Option<&HashMap<PathBuf, String>>,
128) -> Vec<Completion> {
129    complete_in(None, line_prefix, doc_text, files)
130}
131
132/// [`complete`] for the project rooted at `project_root`, whose path
133/// dependencies decide which unit names are offered (#2223).
134pub fn complete_in(
135    project_root: Option<&Path>,
136    line_prefix: &str,
137    doc_text: &str,
138    files: Option<&HashMap<PathBuf, String>>,
139) -> Vec<Completion> {
140    // #2223: the project's own files and its dependencies' reachable units,
141    // resolved once per request and only by the branches that name units.
142    let split = || split_dependency_files(files, project_root);
143    // 1. Inside `consumes U { … <cursor>` — the capabilities U exports.
144    if let Some(unit) = consumes_brace_unit(line_prefix) {
145        return capabilities_of_unit(&unit, doc_text, &split())
146            .into_iter()
147            .map(|c| {
148                let detail = format!(
149                    "capability exported by `{unit}`{}",
150                    stability_suffix(experimental(&format!("{unit}.{c}")))
151                );
152                Completion::item(c, CompletionKind::Capability, Some(detail))
153            })
154            .collect();
155    }
156    // 2. After `consumes <prefix>` — consumable unit names.
157    if is_consumes_target(line_prefix) {
158        return consumable_units(doc_text, &split());
159    }
160    // 2a. #2223: after `uses <prefix>` — the commons a `uses` can name: the
161    //     project's own, and each dependency's exported ones as
162    //     `<alias>.<unit>`.
163    if is_uses_target(line_prefix) {
164        return usable_commons(doc_text, &split());
165    }
166    // 3. After `given …` — in-scope capabilities.
167    if is_given_position(line_prefix) {
168        return in_scope_capabilities(doc_text, &split());
169    }
170    // 4. `UpperIdent.<cursor>` — name-receiver members: sum variants, refined/
171    //    opaque `of`/`unsafe`, capability ops, or built-in type statics.
172    if let Some(receiver) = member_receiver(line_prefix) {
173        return member_candidates(&receiver, doc_text, files);
174    }
175    // v0.124 (slice 3): the non-keyword clause/construction contexts, before the
176    // generic type/keyword/expression cells they would otherwise fall into.
177    // 4a. `Type { <cursor>` — record field names on construction.
178    if let Some(recv) = record_construction_receiver(line_prefix) {
179        let fields = record_field_names(&recv, doc_text, files);
180        if !fields.is_empty() {
181            return fields;
182        }
183    }
184    // 4b. `from <cursor>` — the service protocols.
185    if after_clause_keyword(line_prefix, "from") {
186        return protocol_candidates();
187    }
188    // 4c. `on <cursor>` — the handler kinds.
189    if after_clause_keyword(line_prefix, "on") {
190        return handler_kind_candidates();
191    }
192    // 4d. `by <cursor>` — the project's actor names.
193    if after_clause_keyword(line_prefix, "by") {
194        return actor_candidates(doc_text, files);
195    }
196    // 4e. `exports <cursor>` — the export kinds (adapter).
197    if after_clause_keyword(line_prefix, "exports") {
198        return export_kind_candidates();
199    }
200    // 4f. `provides <cursor>` — the in-scope capabilities to implement.
201    if after_clause_keyword(line_prefix, "provides") {
202        return in_scope_capabilities(doc_text, &split());
203    }
204    // 4g. `where <cursor>` — the closed refinement-predicate vocabulary.
205    // Shared by a type declaration's `type X = Base where <cursor>` and
206    // (#472) a match arm's `_ where <cursor>`; both reuse the same closed
207    // predicate catalogue, so one branch serves both surfaces. Excludes a
208    // `for all x: T, … where <cursor>` binder's clause, which takes an
209    // arbitrary `Bool` expression, not the predicate catalogue — that case
210    // falls through to expression-position candidates below.
211    if after_clause_keyword(line_prefix, "where") && !is_for_all_where(line_prefix) {
212        return predicate_name_candidates();
213    }
214    // 5. Type position (`: T`, `-> T`, `[ … ]` type args) — built-ins, the
215    //    `bynk`-surface transparent types, and project type declarations.
216    if is_type_position(line_prefix) {
217        return type_candidates(doc_text, files);
218    }
219    // 6. Keyword position (a bare word at a declaration/statement start) — the
220    //    reserved keywords plus declaration snippets.
221    if is_keyword_position(line_prefix) {
222        return keyword_and_snippet_candidates();
223    }
224    // 7. Expression position (after `=`/`(`/`,`/`=>`/a binary operator) — a value
225    //    starts here: the constructor keywords + in-scope type names. In-scope
226    //    locals/params (and, from slice 3, free functions) are appended
227    //    handler-side, where the analysis cache lives (ADR 0093 D3).
228    if is_expression_position(line_prefix) {
229        return expression_candidates(doc_text, files);
230    }
231    Vec::new()
232}
233
234// -- Cursor-context detection (line-prefix scanning) --
235
236/// `consumes U { … ` with the brace still open at the cursor → `Some(U)`.
237fn consumes_brace_unit(line: &str) -> Option<String> {
238    let idx = line.rfind("consumes")?;
239    let after = &line[idx + "consumes".len()..];
240    let open = after.find('{')?;
241    // The brace must still be open up to the cursor (no closing brace after it).
242    if after[open + 1..].contains('}') {
243        return None;
244    }
245    let unit = after[..open].trim();
246    if unit.is_empty() || !is_qualified_name(unit) {
247        return None;
248    }
249    Some(unit.to_string())
250}
251
252/// `consumes <partial>` with no brace or `as` yet → completing the target name.
253fn is_consumes_target(line: &str) -> bool {
254    let Some(idx) = line.rfind("consumes") else {
255        return false;
256    };
257    // `consumes` must be a standalone keyword (preceded by start/whitespace).
258    if !line[..idx]
259        .chars()
260        .last()
261        .map(|c| c.is_whitespace())
262        .unwrap_or(true)
263    {
264        return false;
265    }
266    let after = &line[idx + "consumes".len()..];
267    // Need at least one separating space, and no `{`, `}`, or `as` yet.
268    after.starts_with(char::is_whitespace)
269        && !after.contains('{')
270        && !after.contains('}')
271        && !after.split_whitespace().any(|w| w == "as")
272}
273
274/// #2223: `uses <partial>` as a unit header clause → completing the target.
275fn is_uses_target(line: &str) -> bool {
276    let trimmed = line.trim_start();
277    let Some(after) = trimmed.strip_prefix("uses") else {
278        return false;
279    };
280    after.starts_with(char::is_whitespace) && after.split_whitespace().count() <= 1
281}
282
283/// The cursor is inside a `given` list (after `given`, before the `{` body).
284fn is_given_position(line: &str) -> bool {
285    let Some(idx) = line.rfind("given") else {
286        return false;
287    };
288    if !line[..idx]
289        .chars()
290        .last()
291        .map(|c| c.is_whitespace())
292        .unwrap_or(true)
293    {
294        return false;
295    }
296    let after = &line[idx + "given".len()..];
297    if !after.starts_with(char::is_whitespace) {
298        return false;
299    }
300    // Still in the given list while only capability names, dots, commas and
301    // whitespace follow — a `{` opens the handler body.
302    after
303        .chars()
304        .all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | ',' | ' ' | '\t'))
305}
306
307fn is_qualified_name(s: &str) -> bool {
308    !s.is_empty()
309        && s.split('.').all(|seg| {
310            !seg.is_empty()
311                && seg.chars().all(|c| c.is_alphanumeric() || c == '_')
312                && !seg.chars().next().unwrap().is_ascii_digit()
313        })
314}
315
316/// The cursor sits in a type position: a return type (`-> T`), a type
317/// annotation/field type (`: T`), or inside a `[ … ]` type-argument list. The
318/// partial type name being typed is stripped before inspecting the preceding
319/// token, so `: Optio` and `-> Eff` both qualify.
320///
321/// Conservative by construction: a list literal `[1, 2` is excluded (its `[` is
322/// not preceded by a type constructor). The one accepted false positive is a
323/// record *construction* value (`Order { id: <cursor>`), lexically identical to
324/// a record field-type declaration — offering type names there is mild noise.
325fn is_type_position(line: &str) -> bool {
326    let head = line
327        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
328        .trim_end();
329    head.ends_with("->") || (head.ends_with(':') && !head.ends_with("::")) || in_type_arg_list(head)
330}
331
332/// `head` ends inside an unclosed `[ … ` whose opening bracket immediately
333/// follows an identifier (a type constructor, e.g. `Option[`, `Result[Int, `) —
334/// as opposed to a bare list-literal `[`.
335fn in_type_arg_list(head: &str) -> bool {
336    let chars: Vec<char> = head.chars().collect();
337    let mut depth = 0i32;
338    let mut opener_after_ident = false;
339    for (i, &c) in chars.iter().enumerate() {
340        match c {
341            '[' => {
342                depth += 1;
343                if depth == 1 {
344                    opener_after_ident =
345                        i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_');
346                }
347            }
348            ']' => depth -= 1,
349            _ => {}
350        }
351    }
352    depth > 0 && opener_after_ident
353}
354
355/// A bare word at a declaration/statement start: the line up to the cursor is
356/// only leading whitespace plus an optional partial identifier (no operators,
357/// colons, or brackets). Fires on an empty line too. Disjoint from
358/// `is_type_position` (an internal helper), whose triggers (`:`/`->`/`[`) make
359/// this false.
360pub fn is_keyword_position(line: &str) -> bool {
361    line.trim().chars().all(|c| c.is_alphanumeric() || c == '_')
362}
363
364/// The cursor sits where a **value** expression is expected — after `=`/`(`/`,`,
365/// a `=>` lambda arrow, or a binary operator — so in-scope locals are offered
366/// (v0.31, ADR 0064). Conservative: covers the common positions, excludes the
367/// type arrow `->`. (The handler also offers locals at keyword position.)
368pub fn is_expression_position(line: &str) -> bool {
369    let head = line
370        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
371        .trim_end();
372    if head.ends_with("->") {
373        return false; // a return/param type, not a value
374    }
375    if head.ends_with("=>") {
376        return true; // a lambda body
377    }
378    matches!(
379        head.chars().last(),
380        Some('=' | '(' | ',' | '[' | '+' | '-' | '*' | '/' | '<' | '>' | '&' | '|')
381    )
382}
383
384/// `UpperIdent.<partial>` at the cursor → `Some("UpperIdent")` — a name
385/// receiver whose members are statically enumerable (a sum/refined/opaque
386/// type or a capability). Conservative: the receiver is a **single**
387/// uppercase-initial identifier, not itself a `.`-qualified segment (so
388/// `bynk.cloudflare.` and `a.B.` are excluded) and not a number (so the
389/// decimal `1.` is excluded). A lowercase `x.` is a *value* receiver — deferred
390/// to slice 3 — and yields `None`.
391fn member_receiver(line: &str) -> Option<String> {
392    // Drop the partial member name being typed, then require a trailing dot.
393    let head = line
394        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
395        .strip_suffix('.')?;
396    // The receiver is the identifier immediately before that dot. Advance past
397    // the matched char by its UTF-8 length: a multi-byte non-identifier char
398    // (`"`, `€`, `—`, …) would make `i + 1` land mid-codepoint and panic.
399    let start = head
400        .char_indices()
401        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
402        .map_or(0, |(i, c)| i + c.len_utf8());
403    let recv = &head[start..];
404    let first = recv.chars().next()?;
405    if !first.is_ascii_uppercase() {
406        return None;
407    }
408    // Reject a `.`-qualified receiver (`a.B.`): the char before it is a dot.
409    if head[..start].ends_with('.') {
410        return None;
411    }
412    Some(recv.to_string())
413}
414
415/// v0.124 (slice 3): the cursor is at a field-*name* position of a record
416/// construction — inside an unclosed `{` opened immediately after an
417/// uppercase-initial type name (`Order { <cursor>` / `Order { id: 1, <cursor>`),
418/// with the current field segment not yet past its `:` (a field *type*
419/// position, left to [`is_type_position`]). Returns the record type name.
420fn record_construction_receiver(line: &str) -> Option<String> {
421    // The innermost `{` still open at the cursor.
422    let bytes = line.as_bytes();
423    let mut depth = 0i32;
424    let mut open = None;
425    for i in (0..bytes.len()).rev() {
426        match bytes[i] {
427            b'}' => depth += 1,
428            b'{' => {
429                if depth == 0 {
430                    open = Some(i);
431                    break;
432                }
433                depth -= 1;
434            }
435            _ => {}
436        }
437    }
438    let open = open?;
439    // Only at a name position: the current field (since the last comma) has no
440    // `:` yet — else the cursor is in that field's type.
441    let current = line[open + 1..].rsplit(',').next().unwrap_or("");
442    if current.contains(':') {
443        return None;
444    }
445    // The receiver is the uppercase-initial identifier immediately before `{`.
446    let head = line[..open].trim_end();
447    let start = head
448        .char_indices()
449        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
450        .map_or(0, |(i, c)| i + c.len_utf8());
451    let recv = &head[start..];
452    if recv.chars().next()?.is_ascii_uppercase() {
453        Some(recv.to_string())
454    } else {
455        None
456    }
457}
458
459/// v0.131: the CORS policy fields, offered at a field-name position inside a
460/// `cors { }` block. `Allow-Methods` is deliberately absent (derived from the
461/// routes), so the closed set is these four.
462pub const CORS_FIELDS: &[(&str, &str)] = &[
463    (
464        "origins",
465        "the allowed origins — an exact allowlist, or `[\"*\"]`",
466    ),
467    (
468        "headers",
469        "the `Access-Control-Allow-Headers` a preflight advertises",
470    ),
471    (
472        "credentials",
473        "whether credentialed requests are allowed (`true`/`false`)",
474    ),
475    (
476        "maxAge",
477        "how long a browser may cache the preflight (a `Duration`)",
478    ),
479];
480
481/// v0.141 (ADR 0164): the security-headers policy fields, offered at a field-name
482/// position inside a `security { }` block. The closed set is these two.
483pub const SECURITY_FIELDS: &[(&str, &str)] = &[
484    (
485        "nosniff",
486        "stamp `X-Content-Type-Options: nosniff` (`true`/`false`, default `true`)",
487    ),
488    (
489        "hsts",
490        "opt in to `Strict-Transport-Security` — the `max-age` as a `Duration`",
491    ),
492];
493
494/// v0.142 (ADR 0165): the request-limits policy fields, offered at a field-name
495/// position inside a `limits { }` block. The closed set is this one.
496pub const LIMITS_FIELDS: &[(&str, &str)] = &[(
497    "maxBody",
498    "the maximum request body size in bytes (a positive `Int`)",
499)];
500
501/// v0.140 (ADR 0163): the `@cache` handler-annotation arguments, offered at an
502/// argument-name position inside `@cache( … )`. `maxAge` is required (the freshness
503/// window); `scope` is optional (`public`/`private`, default `private`), as is
504/// `immutable` (caching raw responses; default `false`). The conditional
505/// `ETag`/`304` half is automatic and has no surface.
506pub const CACHE_ARGS: &[(&str, &str)] = &[
507    (
508        "maxAge",
509        "the freshness window — a whole-second `Duration` (e.g. `5.minutes`) lowered to `Cache-Control: max-age`",
510    ),
511    (
512        "scope",
513        "`public` or `private` (default `private` — a shared cache stores only on `public`)",
514    ),
515    (
516        "immutable",
517        "`true` promises the response never changes (a content-addressed path) — appends `immutable` to `Cache-Control`; purge it through `EdgeCache` if it can change; default `false`",
518    ),
519];
520
521/// v0.142 (ADR 0165): the `@limit` handler-annotation arguments, offered at an
522/// argument-name position inside `@limit( … )`. Its one arg is `maxBody`, the
523/// request-body byte ceiling above which a `413` is synthesised before the body
524/// is read.
525pub const LIMIT_ARGS: &[(&str, &str)] = &[(
526    "maxBody",
527    "the maximum request body size in bytes (a positive `Int`) — a `413` is synthesised past it",
528)];
529
530/// The `@raw` handler-annotation arguments, offered at an argument-name position
531/// inside `@raw( … )`. Its one arg is `contentType`, the media type the route accepts
532/// (`"*/*"` for any).
533pub const RAW_ARGS: &[(&str, &str)] = &[(
534    "contentType",
535    "the media type the route accepts, a string literal (`\"*/*\"` for any) — another `Content-Type` is a `415`",
536)];
537
538/// The byte index of the innermost `{` still open at `offset` (a naive scan that,
539/// like [`record_construction_receiver`], does not skip braces inside strings or
540/// comments — acceptable for a completion heuristic).
541fn innermost_open_brace(text: &str, offset: usize) -> Option<usize> {
542    let bytes = text.as_bytes();
543    let end = offset.min(bytes.len());
544    let mut depth = 0i32;
545    for i in (0..end).rev() {
546        match bytes[i] {
547            b'}' => depth += 1,
548            b'{' => {
549                if depth == 0 {
550                    return Some(i);
551                }
552                depth -= 1;
553            }
554            _ => {}
555        }
556    }
557    None
558}
559
560/// The bare word immediately before byte index `open` (skipping trailing
561/// whitespace), e.g. the `cors` before a `cors {`.
562fn word_before_brace(text: &str, open: usize) -> &str {
563    let head = text[..open].trim_end();
564    let start = head
565        .char_indices()
566        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
567        .map_or(0, |(i, c)| i + c.len_utf8());
568    &head[start..]
569}
570
571/// v0.131: the cursor is at a field-*name* position inside a `cors { … }` block —
572/// the innermost open brace is opened by `cors`, and the current field segment
573/// (since the last `,` or newline) has no `:` yet (a value position).
574pub fn in_cors_field_position(text: &str, offset: usize) -> bool {
575    let Some(open) = innermost_open_brace(text, offset) else {
576        return false;
577    };
578    if word_before_brace(text, open) != "cors" {
579        return false;
580    }
581    // `offset` arrives on a char boundary from the position converter, but
582    // slice defensively — a mid-codepoint offset must degrade, not panic.
583    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
584        return false;
585    };
586    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
587    !current.contains(':')
588}
589
590/// v0.141: the cursor is at a field-*name* position inside a `security { … }`
591/// block — the innermost open brace is opened by `security`, and the current
592/// field segment has no `:` yet. Mirrors [`in_cors_field_position`].
593pub fn in_security_field_position(text: &str, offset: usize) -> bool {
594    let Some(open) = innermost_open_brace(text, offset) else {
595        return false;
596    };
597    if word_before_brace(text, open) != "security" {
598        return false;
599    }
600    // `offset` arrives on a char boundary from the position converter, but
601    // slice defensively — a mid-codepoint offset must degrade, not panic.
602    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
603        return false;
604    };
605    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
606    !current.contains(':')
607}
608
609/// v0.142 (ADR 0165): the cursor is at a field-*name* position inside a
610/// `limits { … }` block — the innermost open brace is opened by `limits`, and the
611/// current field segment has no `:` yet. Mirrors [`in_security_field_position`].
612pub fn in_limits_field_position(text: &str, offset: usize) -> bool {
613    let Some(open) = innermost_open_brace(text, offset) else {
614        return false;
615    };
616    if word_before_brace(text, open) != "limits" {
617        return false;
618    }
619    // `offset` arrives on a char boundary from the position converter, but
620    // slice defensively — a mid-codepoint offset must degrade, not panic.
621    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
622        return false;
623    };
624    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
625    !current.contains(':')
626}
627
628/// #2134: the cursor is at an entry-*name* position inside a suite's or case's
629/// `env { … }` block — the innermost open brace is opened by `env`, and the
630/// current entry (since the last newline or `,`) has no `=` yet.
631pub fn in_env_name_position(text: &str, offset: usize) -> bool {
632    let Some(open) = innermost_open_brace(text, offset) else {
633        return false;
634    };
635    if word_before_brace(text, open) != "env" {
636        return false;
637    }
638    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
639        return false;
640    };
641    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
642    !current.contains('=')
643}
644
645/// #2134: the names an `env` block can usefully give — each secret an actor
646/// declares (`secret = "…"`, a Bearer, Signature or Foreign scheme) and each
647/// literal name a `Secrets.get("…")` reads, across the buffer and the project's
648/// files. A lexical scan, so it works mid-edit; sorted and deduplicated.
649pub fn env_name_candidates(
650    doc_text: &str,
651    files: Option<&HashMap<PathBuf, String>>,
652) -> Vec<Completion> {
653    let mut names: BTreeMap<String, &'static str> = BTreeMap::new();
654    let mut scan = |src: &str| {
655        for (marker, detail) in [
656            ("secret = \"", "a secret an actor declares"),
657            ("Secrets.get(\"", "a name `Secrets.get` reads"),
658        ] {
659            let mut rest = src;
660            while let Some(i) = rest.find(marker) {
661                rest = &rest[i + marker.len()..];
662                if let Some(end) = rest.find('"') {
663                    let name = &rest[..end];
664                    if !name.is_empty() && !name.contains(char::is_whitespace) {
665                        names.entry(name.to_string()).or_insert(detail);
666                    }
667                }
668            }
669        }
670    };
671    scan(doc_text);
672    if let Some(files) = files {
673        for src in files.values() {
674            scan(src);
675        }
676    }
677    names
678        .into_iter()
679        .map(|(name, detail)| {
680            let mut c = Completion::item(name.clone(), CompletionKind::Field, Some(detail.into()));
681            c.insert_text = Some(format!("{name} = \"\""));
682            c
683        })
684        .collect()
685}
686
687/// v0.131: the cursor is at a service-body item start — a bare word inside a
688/// `service … {` block (not a nested block) — where `cors` may begin, alongside
689/// the `on` handler kinds. Gated on `is_keyword_position` so it only fires at a
690/// fresh item start, and on the enclosing brace's header line naming `service`.
691pub fn in_service_body_item_position(text: &str, offset: usize, line_prefix: &str) -> bool {
692    if !is_keyword_position(line_prefix) {
693        return false;
694    }
695    let Some(open) = innermost_open_brace(text, offset) else {
696        return false;
697    };
698    let header_start = text[..open].rfind('\n').map_or(0, |i| i + 1);
699    text[header_start..open].contains("service ")
700}
701
702/// The byte index of the innermost `(` still open at `offset` — the paren analogue
703/// of [`innermost_open_brace`], for `@cache( … )` argument-position detection.
704fn innermost_open_paren(text: &str, offset: usize) -> Option<usize> {
705    let bytes = text.as_bytes();
706    let end = offset.min(bytes.len());
707    let mut depth = 0i32;
708    for i in (0..end).rev() {
709        match bytes[i] {
710            b')' => depth += 1,
711            b'(' => {
712                if depth == 0 {
713                    return Some(i);
714                }
715                depth -= 1;
716            }
717            _ => {}
718        }
719    }
720    None
721}
722
723/// v0.140 (ADR 0163): the cursor is at an argument-*name* position inside a
724/// `@cache( … )` — the innermost open paren is opened by the `cache` annotation
725/// (`@cache`, not a bare `cache(` call), and the current argument segment (since
726/// the last `,`) has no `:` yet (a name position, not a value one).
727pub fn in_cache_arg_position(text: &str, offset: usize) -> bool {
728    let Some(open) = innermost_open_paren(text, offset) else {
729        return false;
730    };
731    if word_before_brace(text, open) != "cache" {
732        return false;
733    }
734    // Distinguish the `@cache` annotation from any ordinary `cache(...)` call: the
735    // word must be immediately preceded by `@`.
736    let head = text[..open].trim_end();
737    let before_cache = head[..head.len() - "cache".len()].trim_end();
738    if !before_cache.ends_with('@') {
739        return false;
740    }
741    // `offset` arrives on a char boundary from the position converter, but
742    // slice defensively — a mid-codepoint offset must degrade, not panic.
743    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
744        return false;
745    };
746    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
747    !current.contains(':')
748}
749
750/// v0.142 (ADR 0165): the cursor is at an argument-*name* position inside a
751/// `@limit( … )` — the innermost open paren is opened by the `limit` annotation
752/// (`@limit`, not a bare `limit(` call), and the current argument segment has no
753/// `:` yet. Mirrors [`in_cache_arg_position`].
754pub fn in_limit_arg_position(text: &str, offset: usize) -> bool {
755    let Some(open) = innermost_open_paren(text, offset) else {
756        return false;
757    };
758    if word_before_brace(text, open) != "limit" {
759        return false;
760    }
761    // Distinguish the `@limit` annotation from any ordinary `limit(...)` call: the
762    // word must be immediately preceded by `@`.
763    let head = text[..open].trim_end();
764    let before_limit = head[..head.len() - "limit".len()].trim_end();
765    if !before_limit.ends_with('@') {
766        return false;
767    }
768    // P3 (#2342): `capability X = RateLimit @limit(…)` is an instance option,
769    // not the handler annotation, so `maxBody` doesn't belong there.
770    if on_capability_declaration(text, open) {
771        return false;
772    }
773    // `offset` arrives on a char boundary from the position converter, but
774    // slice defensively — a mid-codepoint offset must degrade, not panic.
775    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
776        return false;
777    };
778    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
779    !current.contains(':')
780}
781
782/// Whether the `(` at `open` is on a `capability X = Base @opt(…)` line: an
783/// instance option, not a handler annotation.
784fn on_capability_declaration(text: &str, open: usize) -> bool {
785    let line_start = text[..open].rfind('\n').map_or(0, |i| i + 1);
786    let line = text[line_start..open].trim_start();
787    line.starts_with("capability ") && line.contains('=')
788}
789
790/// P3 (#2342): the values offered for an instance option at its value
791/// position, `capability X = RateLimit @period(▮`: the rate limiting windows.
792/// Empty anywhere else.
793pub fn instance_option_values(
794    text: &str,
795    offset: usize,
796) -> &'static [(&'static str, &'static str)] {
797    let Some(open) = innermost_open_paren(text, offset) else {
798        return &[];
799    };
800    if !on_capability_declaration(text, open) {
801        return &[];
802    }
803    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
804        return &[];
805    };
806    if !seg.trim().is_empty() {
807        return &[];
808    }
809    let head = text[..open].trim_end();
810    let before = head[..head.len() - word_before_brace(text, open).len()].trim_end();
811    if !before.ends_with('@') {
812        return &[];
813    }
814    match word_before_brace(text, open) {
815        "period" => RATELIMIT_PERIODS,
816        _ => &[],
817    }
818}
819
820/// P3 (#2342): the instance completions on a `capability X = …` line, as
821/// `(label, detail, insert text)`: after the `=`, each instantiable platform
822/// capability; after `= <Base> `, a snippet of the base's required options
823/// (`@limit(${1:100}) @period(${2:60.seconds})`), when it has any. Empty
824/// anywhere else.
825pub fn instance_declaration_completions(line: &str) -> Vec<(String, String, String)> {
826    let line = line.trim_start();
827    let Some(rest) = line.strip_prefix("capability ") else {
828        return Vec::new();
829    };
830    let Some((_, after)) = rest.split_once('=') else {
831        return Vec::new();
832    };
833    let kinds = || {
834        bynk_check::firstparty::UNITS
835            .iter()
836            .flat_map(|u| u.instantiable.iter().map(move |i| (u.name, i)))
837    };
838    let typed = after.trim_start();
839    if !typed.contains(char::is_whitespace) {
840        // The base being typed (or not yet begun).
841        return kinds()
842            .filter(|(_, i)| i.capability.starts_with(typed))
843            .map(|(unit, i)| {
844                (
845                    i.capability.to_string(),
846                    format!("an instance of `{unit}`'s `{}`", i.capability),
847                    i.capability.to_string(),
848                )
849            })
850            .collect();
851    }
852    let (base, tail) = typed.split_once(char::is_whitespace).unwrap_or((typed, ""));
853    if !tail.trim().is_empty() {
854        return Vec::new();
855    }
856    let Some((_, kind)) = kinds().find(|(_, i)| i.capability == base) else {
857        return Vec::new();
858    };
859    if kind.required.is_empty() {
860        return Vec::new();
861    }
862    let snippet: Vec<String> = kind
863        .required
864        .iter()
865        .enumerate()
866        .map(|(n, o)| format!("@{o}(${{{}:{}}})", n + 1, option_sample(o)))
867        .collect();
868    let label: Vec<String> = kind.required.iter().map(|o| format!("@{o}(…)")).collect();
869    vec![(
870        label.join(" "),
871        format!("the options every `{base}` instance needs"),
872        snippet.join(" "),
873    )]
874}
875
876/// A sample value of instance option `option`, as a snippet offers it.
877fn option_sample(option: &str) -> &'static str {
878    match option {
879        "limit" => "100",
880        "period" => "60.seconds",
881        "jurisdiction" => "\"eu\"",
882        _ => "\"legacy-billing\"",
883    }
884}
885
886/// The windows a `RateLimit` instance's `@period` takes.
887pub const RATELIMIT_PERIODS: &[(&str, &str)] = &[
888    (
889        "10.seconds",
890        "a 10-second window — one of the two the platform offers",
891    ),
892    (
893        "60.seconds",
894        "a 60-second window — one of the two the platform offers",
895    ),
896];
897
898/// The cursor is at an argument-*name* position inside a `@raw( … )` annotation.
899/// Mirrors [`in_limit_arg_position`].
900pub fn in_raw_arg_position(text: &str, offset: usize) -> bool {
901    let Some(open) = innermost_open_paren(text, offset) else {
902        return false;
903    };
904    if word_before_brace(text, open) != "raw" {
905        return false;
906    }
907    let head = text[..open].trim_end();
908    let before_raw = head[..head.len() - "raw".len()].trim_end();
909    if !before_raw.ends_with('@') {
910        return false;
911    }
912    let Some(seg) = text.get(open + 1..offset.min(text.len())) else {
913        return false;
914    };
915    let current = seg.rsplit(['\n', ',']).next().unwrap_or("");
916    !current.contains(':')
917}
918
919/// v0.124 (slice 3): the cursor is completing the argument to a leading clause
920/// keyword — `from`/`on`/`by`/`exports`/`provides <cursor>` — with only a
921/// partial identifier typed after the keyword. `kw` must be a standalone word
922/// (line start or whitespace before it), so a field named `from` or the `on`
923/// inside `session` does not trigger it.
924fn after_clause_keyword(line: &str, kw: &str) -> bool {
925    let Some(idx) = line.rfind(kw) else {
926        return false;
927    };
928    if !line[..idx]
929        .chars()
930        .last()
931        .map(char::is_whitespace)
932        .unwrap_or(true)
933    {
934        return false;
935    }
936    let after = &line[idx + kw.len()..];
937    after.starts_with(char::is_whitespace)
938        && after
939            .trim_start()
940            .chars()
941            .all(|c| c.is_alphanumeric() || c == '_')
942}
943
944/// #472 (finding 2, PR #827 review): `for all x: T, y: U where <cursor>` — a
945/// generative-test binder's clause. Unlike a type declaration's or a refined
946/// pattern's `where`, this one takes an arbitrary `Bool` expression over the
947/// bound names, not the closed predicate catalogue; `after_clause_keyword`
948/// only matches the keyword text, so this excludes that one `where` position
949/// from the predicate-vocabulary branch. `line`'s last `where` is checked
950/// against the text immediately before it, ignoring the binding list, since
951/// `for`/`all` are contextual (not lexer) keywords only meaningful in this
952/// exact `for all` sequence.
953fn is_for_all_where(line: &str) -> bool {
954    let Some(idx) = line.rfind("where") else {
955        return false;
956    };
957    let head = line[..idx].trim_start();
958    let Some(after_for) = head.strip_prefix("for") else {
959        return false;
960    };
961    // `for` must be a standalone word (a whitespace boundary), not a prefix
962    // of a longer identifier (`format(...)`).
963    if !after_for.starts_with(char::is_whitespace) {
964        return false;
965    }
966    let Some(after_all) = after_for.trim_start().strip_prefix("all") else {
967        return false;
968    };
969    // Likewise `all` — `allocate` is not the `for all` keyword.
970    after_all.is_empty() || after_all.starts_with(char::is_whitespace)
971}
972
973/// v0.124 (slice 3): the cursor sits in a contract-clause predicate —
974/// `requires <name>: <cursor>` or `ensures <name>: <cursor>` — where the
975/// enclosing function's parameters (and, for an `ensures`, `result`) are in
976/// scope. Returns `Some(is_ensures)`; the parameters themselves are resolved
977/// handler-side from the enclosing `fn` (needs the cursor offset).
978pub fn contract_clause_kind(line: &str) -> Option<bool> {
979    let colon = line.rfind(':')?;
980    let clause = line[..colon].trim();
981    for (kw, is_ensures) in [("requires", false), ("ensures", true)] {
982        if let Some(rest) = clause.strip_prefix(kw) {
983            let rest = rest.trim();
984            if !rest.is_empty() && rest.chars().all(|c| c.is_alphanumeric() || c == '_') {
985                return Some(is_ensures);
986            }
987        }
988    }
989    None
990}
991
992/// The record fields of a project (or embedded-surface) type named `name`, as
993/// field-name completions — the construction-position half of what
994/// [`value_member_candidates`] offers on a value receiver.
995///
996/// Finding #62: stops at the first unit declaring `name` rather than unioning
997/// fields across every matching unit — `for_each_unit` can otherwise yield
998/// the live buffer *and* that same file's stale on-disk copy, and a name
999/// removed from the buffer's declaration must not resurface from the disk
1000/// copy's fields.
1001fn record_field_names(
1002    name: &str,
1003    doc_text: &str,
1004    files: Option<&HashMap<PathBuf, String>>,
1005) -> Vec<Completion> {
1006    let mut out: Vec<Completion> = Vec::new();
1007    let mut found = false;
1008    for_each_unit(doc_text, files, |unit| {
1009        if found {
1010            return;
1011        }
1012        let items = match unit {
1013            SourceUnit::Commons(c) => &c.items,
1014            SourceUnit::Context(c) => &c.items,
1015            SourceUnit::Adapter(a) => &a.items,
1016            _ => return,
1017        };
1018        for item in items {
1019            if let CommonsItem::Type(t) = item
1020                && t.name.name == name
1021                && let TypeBody::Record(r) = &t.body
1022            {
1023                found = true;
1024                for f in &r.fields {
1025                    out.push(
1026                        Completion::item(
1027                            f.name.name.clone(),
1028                            CompletionKind::Field,
1029                            Some(format!("field of `{name}`")),
1030                        )
1031                        .with_doc(f.documentation.as_deref()),
1032                    );
1033                }
1034                return;
1035            }
1036        }
1037    });
1038    out
1039}
1040
1041/// v0.124 (slice 3): the variants of a project (or embedded-surface) sum type
1042/// named `name`, as pattern completions — the `is`/`match` candidate set once
1043/// the scrutinee's type is known (resolved handler-side from `expr_types`).
1044/// `pub` so the completion handler can offer them at an `is` position.
1045///
1046/// Finding #62: stops at the first unit declaring `name`, for the same
1047/// buffer-vs-stale-disk-copy reason as `record_field_names` above.
1048pub fn sum_type_variants(
1049    name: &str,
1050    doc_text: &str,
1051    files: Option<&HashMap<PathBuf, String>>,
1052) -> Vec<Completion> {
1053    let mut out: Vec<Completion> = Vec::new();
1054    let mut found = false;
1055    for_each_unit(doc_text, files, |unit| {
1056        if found {
1057            return;
1058        }
1059        let items = match unit {
1060            SourceUnit::Commons(c) => &c.items,
1061            SourceUnit::Context(c) => &c.items,
1062            SourceUnit::Adapter(a) => &a.items,
1063            _ => return,
1064        };
1065        for item in items {
1066            if let CommonsItem::Type(t) = item
1067                && t.name.name == name
1068                && let TypeBody::Sum(s) = &t.body
1069            {
1070                found = true;
1071                for v in &s.variants {
1072                    out.push(
1073                        Completion::item(
1074                            v.name.name.clone(),
1075                            CompletionKind::Variant,
1076                            Some(format!("variant of `{name}`")),
1077                        )
1078                        .with_doc(v.documentation.as_deref()),
1079                    );
1080                }
1081                return;
1082            }
1083        }
1084    });
1085    out
1086}
1087
1088/// v0.145 (ADR 0169, base gap for #565): the variants offerable for a scrutinee
1089/// `Ty` at a pattern position. A user-declared sum's variants come from source
1090/// (`sum_type_variants`); the built-in `Result`/`Option` variants do not (they
1091/// are not declared types, so `sum_type_variants` can't see them) and are
1092/// intrinsic here. This is why match-arm / `is` completion now fires for a
1093/// `Result`/`Option` scrutinee, not only a user sum.
1094pub fn variants_for_ty(
1095    ty: TyId,
1096    tys: &Types,
1097    doc_text: &str,
1098    files: Option<&HashMap<PathBuf, String>>,
1099) -> Vec<Completion> {
1100    match &*tys.get(ty) {
1101        Ty::Named { name, .. } => sum_type_variants(name, doc_text, files),
1102        Ty::Result(..) => built_in_variants(&["Ok", "Err"], "Result"),
1103        Ty::Option(..) => built_in_variants(&["Some", "None"], "Option"),
1104        _ => Vec::new(),
1105    }
1106}
1107
1108/// v0.145 (ADR 0169, nested-variant completion for #565): the variants offerable
1109/// inside `OuterVariant(‸` within a match arm — the payload field type's
1110/// variants. Resolves the single-field payload type of `outer_variant` on the
1111/// scrutinee `ty` (the same shape as `bynk-emit`'s `payload_field_ty`):
1112/// `Result`/`Option`/`HttpResult` generic args come straight off the `Ty`, and a
1113/// user-declared sum's field type is walked from source. `Ok`/`Err` inside
1114/// `Some(‸)` on an `Option[Result[…]]` is the headline case.
1115pub fn nested_variant_completions(
1116    ty: TyId,
1117    tys: &Types,
1118    outer_variant: &str,
1119    doc_text: &str,
1120    files: Option<&HashMap<PathBuf, String>>,
1121) -> Vec<Completion> {
1122    match &*tys.get(ty) {
1123        Ty::Result(t, e) => match outer_variant {
1124            "Ok" => variants_for_ty(*t, tys, doc_text, files),
1125            "Err" => variants_for_ty(*e, tys, doc_text, files),
1126            _ => Vec::new(),
1127        },
1128        Ty::HttpResult(t) if outer_variant == "Ok" => variants_for_ty(*t, tys, doc_text, files),
1129        Ty::Option(t) if outer_variant == "Some" => variants_for_ty(*t, tys, doc_text, files),
1130        Ty::Named {
1131            kind: NamedKind::Sum,
1132            name,
1133            ..
1134        } => payload_type_ref_variants(name, outer_variant, doc_text, files),
1135        _ => Vec::new(),
1136    }
1137}
1138
1139/// The built-in variant names of `Result`/`Option` as `Variant` completions.
1140fn built_in_variants(names: &[&str], of: &str) -> Vec<Completion> {
1141    names
1142        .iter()
1143        .map(|v| {
1144            Completion::item(
1145                (*v).to_string(),
1146                CompletionKind::Variant,
1147                Some(format!("variant of `{of}`")),
1148            )
1149        })
1150        .collect()
1151}
1152
1153/// The variants of the payload field type of `variant` on the user-declared sum
1154/// `sum_name`, walked from source (the LSP holds no resolved type map, so it
1155/// reads the field's `TypeRef` off the parsed decl — the source-side analogue of
1156/// `payload_field_ty`'s `commons.types` lookup).
1157fn payload_type_ref_variants(
1158    sum_name: &str,
1159    variant: &str,
1160    doc_text: &str,
1161    files: Option<&HashMap<PathBuf, String>>,
1162) -> Vec<Completion> {
1163    let mut field_ty: Option<TypeRef> = None;
1164    for_each_unit(doc_text, files, |unit| {
1165        let items = match unit {
1166            SourceUnit::Commons(c) => &c.items,
1167            SourceUnit::Context(c) => &c.items,
1168            SourceUnit::Adapter(a) => &a.items,
1169            _ => return,
1170        };
1171        for item in items {
1172            if let CommonsItem::Type(t) = item
1173                && t.name.name == sum_name
1174                && let TypeBody::Sum(s) = &t.body
1175                && let Some(v) = s.variants.iter().find(|v| v.name.name == variant)
1176                && let Some(f) = v.payload.first()
1177            {
1178                field_ty = Some(f.type_ref.clone());
1179            }
1180        }
1181    });
1182    match field_ty {
1183        Some(tr) => variants_for_type_ref(&tr, doc_text, files),
1184        None => Vec::new(),
1185    }
1186}
1187
1188/// `variants_for_ty` over an unresolved `TypeRef` (a user-sum payload field). A
1189/// named type's variants come from source; `Result`/`Option` are intrinsic.
1190fn variants_for_type_ref(
1191    tr: &TypeRef,
1192    doc_text: &str,
1193    files: Option<&HashMap<PathBuf, String>>,
1194) -> Vec<Completion> {
1195    match tr {
1196        TypeRef::Named(id) => sum_type_variants(&id.name, doc_text, files),
1197        TypeRef::Result(..) => built_in_variants(&["Ok", "Err"], "Result"),
1198        TypeRef::Option(..) => built_in_variants(&["Some", "None"], "Option"),
1199        _ => Vec::new(),
1200    }
1201}
1202
1203/// The service protocols offerable after `from`.
1204fn protocol_candidates() -> Vec<Completion> {
1205    ["http", "cron", "queue", "websocket"]
1206        .into_iter()
1207        .map(|p| Completion::item(p, CompletionKind::Keyword, Some("service protocol".into())))
1208        .collect()
1209}
1210
1211/// The handler kinds offerable after `on`.
1212fn handler_kind_candidates() -> Vec<Completion> {
1213    [
1214        "call", "GET", "POST", "PUT", "PATCH", "DELETE", "schedule", "message", "open", "close",
1215        "rejected",
1216    ]
1217    .into_iter()
1218    .map(|k| Completion::item(k, CompletionKind::Keyword, Some("handler kind".into())))
1219    .collect()
1220}
1221
1222/// The export kinds offerable after `exports` (adapter).
1223fn export_kind_candidates() -> Vec<Completion> {
1224    ["capability", "transparent", "opaque"]
1225        .into_iter()
1226        .map(|k| Completion::item(k, CompletionKind::Keyword, Some("export kind".into())))
1227        .collect()
1228}
1229
1230/// The closed refinement-predicate vocabulary offerable after `where` —
1231/// shared by a type declaration's refinement and (#472) a match arm's
1232/// `_ where <predicate>`. Mirrors `PredKind::name()`.
1233fn predicate_name_candidates() -> Vec<Completion> {
1234    [
1235        "Matches",
1236        "InRange",
1237        "MinLength",
1238        "MaxLength",
1239        "Length",
1240        "NonNegative",
1241        "Positive",
1242        "NonEmpty",
1243    ]
1244    .into_iter()
1245    .map(|k| {
1246        Completion::item(
1247            k,
1248            CompletionKind::Keyword,
1249            Some("refinement predicate".into()),
1250        )
1251    })
1252    .collect()
1253}
1254
1255/// The project's `actor` names, offerable after `by`.
1256fn actor_candidates(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<Completion> {
1257    let mut out: Vec<Completion> = Vec::new();
1258    // #2135: in a suite, a call-site `by` may also name a reserved principal
1259    // (`Nobody`, `Forged`, `Stale`), which drives the seam to a `401`.
1260    // A lexical test, so it holds mid-edit, when the suite does not parse.
1261    let in_suite = lexer::tokenize(doc_text).ok().is_some_and(|toks| {
1262        toks.iter()
1263            .find(|t| !matches!(t.kind, lexer::TokenKind::Comment))
1264            .is_some_and(|t| t.kind == lexer::TokenKind::Suite)
1265    });
1266    if in_suite {
1267        for (name, doc) in crate::symbols::RESERVED_PRINCIPALS {
1268            out.push(
1269                Completion::item(
1270                    *name,
1271                    CompletionKind::Type,
1272                    Some("reserved test principal".into()),
1273                )
1274                .with_doc(Some(doc)),
1275            );
1276        }
1277    }
1278    let mut seen: BTreeSet<String> = BTreeSet::new();
1279    for_each_unit(doc_text, files, |unit| {
1280        let items = match unit {
1281            SourceUnit::Commons(c) => &c.items,
1282            SourceUnit::Context(c) => &c.items,
1283            SourceUnit::Adapter(a) => &a.items,
1284            _ => return,
1285        };
1286        for item in items {
1287            if let CommonsItem::Actor(a) = item
1288                && seen.insert(a.name.name.clone())
1289            {
1290                out.push(Completion::item(
1291                    a.name.name.clone(),
1292                    CompletionKind::Type,
1293                    Some("actor".into()),
1294                ));
1295            }
1296        }
1297    });
1298    out
1299}
1300
1301/// Built-in type statics — real language statics that are not user-declared, so
1302/// they come from this small table rather than the project parse. Covers the
1303/// numeric parse statics and the JSON codec (v0.22, ADRs 0048/0049), the
1304/// collection `empty` constructors (v0.20b), and `Effect.pure` (v0.5). The full
1305/// real set per ADR 0093 D2 — kept complete and drift-tested
1306/// (`builtin_statics_are_reachable`).
1307pub const BUILTIN_STATICS: &[(&str, &[(&str, &str)])] = &[
1308    ("Int", &[("parse", "parse(s: String) -> Option[Int]")]),
1309    ("Float", &[("parse", "parse(s: String) -> Option[Float]")]),
1310    (
1311        "Json",
1312        &[
1313            ("encode", "encode(value) -> String"),
1314            ("decode", "decode[T](s: String) -> Result[T, JsonError]"),
1315        ],
1316    ),
1317    ("List", &[("empty", "empty() -> List[T]")]),
1318    ("Map", &[("empty", "empty() -> Map[K, V]")]),
1319    ("Effect", &[("pure", "pure(value) -> Effect[T]")]),
1320    (
1321        "Bytes",
1322        &[
1323            ("fromUtf8", "fromUtf8(s: String) -> Bytes"),
1324            ("fromBase64", "fromBase64(s: String) -> Option[Bytes]"),
1325            ("fromHex", "fromHex(s: String) -> Option[Bytes]"),
1326            ("fromBase64Url", "fromBase64Url(s: String) -> Option[Bytes]"),
1327            ("empty", "empty() -> Bytes"),
1328        ],
1329    ),
1330];
1331
1332/// Variants of a built-in sum type (`HttpResult`/`QueueResult`), sourced from
1333/// the AST variant registries so a new variant surfaces in completion for free
1334/// (ADR 0093 D2/G3). Empty for any other receiver.
1335fn builtin_sum_variants(receiver: &str) -> Vec<(String, String)> {
1336    match receiver {
1337        "HttpResult" => bynk_syntax::ast::HTTP_VARIANTS
1338            .iter()
1339            .map(|v| {
1340                (
1341                    v.name.to_string(),
1342                    if v.status == bynk_syntax::ast::AUTHOR_CHOSEN_STATUS {
1343                        "variant of `HttpResult` (4xx/5xx, author-chosen)".to_string()
1344                    } else {
1345                        format!("variant of `HttpResult` ({})", v.status)
1346                    },
1347                )
1348            })
1349            .collect(),
1350        "QueueResult" => bynk_syntax::ast::QUEUE_VARIANTS
1351            .iter()
1352            .map(|v| (v.name.to_string(), "variant of `QueueResult`".to_string()))
1353            .collect(),
1354        _ => Vec::new(),
1355    }
1356}
1357
1358/// Members of a name receiver: built-in type statics, then built-in sum-type
1359/// variants, then — from the project and embedded-surface parse — project sum
1360/// variants, refined/opaque `of`/`unsafe`, or capability operations. Yields `[]`
1361/// when the receiver resolves to none of these (e.g. a plain `type X = Int`
1362/// alias or a record).
1363fn member_candidates(
1364    receiver: &str,
1365    doc_text: &str,
1366    files: Option<&HashMap<PathBuf, String>>,
1367) -> Vec<Completion> {
1368    member_candidates_from(receiver, doc_text, files, &mut BTreeSet::new())
1369}
1370
1371/// [`member_candidates`], with the derived capabilities already followed
1372/// (S4a, #1966). Completion reads unchecked source, so a derivation cycle
1373/// (`capability A = B`, `capability B = A`) can be on screen: each receiver
1374/// is followed once.
1375fn member_candidates_from(
1376    receiver: &str,
1377    doc_text: &str,
1378    files: Option<&HashMap<PathBuf, String>>,
1379    followed: &mut BTreeSet<String>,
1380) -> Vec<Completion> {
1381    followed.insert(receiver.to_string());
1382    if let Some((_, statics)) = BUILTIN_STATICS.iter().find(|(name, _)| *name == receiver) {
1383        return statics
1384            .iter()
1385            .map(|(label, sig)| {
1386                Completion::item(*label, CompletionKind::Member, Some(sig.to_string()))
1387            })
1388            .collect();
1389    }
1390    let mut out: Vec<Completion> = Vec::new();
1391    let mut seen: BTreeSet<String> = BTreeSet::new();
1392    // Built-in sum types (`HttpResult`/`QueueResult`) — variants from the AST
1393    // registry, on the same name-receiver path as project sums (ADR 0093 G3).
1394    // S4a (#1966): a derived capability's (`capability Sessions = Kv`) base,
1395    // whose operations it has.
1396    let mut derived_base: Option<String> = None;
1397    for (label, detail) in builtin_sum_variants(receiver) {
1398        if seen.insert(label.clone()) {
1399            out.push(Completion::item(
1400                label,
1401                CompletionKind::Variant,
1402                Some(detail),
1403            ));
1404        }
1405    }
1406    for_each_unit(doc_text, files, |unit| {
1407        let items = match unit {
1408            SourceUnit::Commons(c) => &c.items,
1409            SourceUnit::Context(c) => &c.items,
1410            SourceUnit::Adapter(a) => &a.items,
1411            _ => return,
1412        };
1413        for item in items {
1414            match item {
1415                CommonsItem::Type(t) if t.name.name == receiver => match &t.body {
1416                    bynk_syntax::ast::TypeBody::Sum(s) => {
1417                        for v in &s.variants {
1418                            if seen.insert(v.name.name.clone()) {
1419                                out.push(
1420                                    Completion::item(
1421                                        v.name.name.clone(),
1422                                        CompletionKind::Variant,
1423                                        Some(format!("variant of `{receiver}`")),
1424                                    )
1425                                    .with_doc(v.documentation.as_deref()),
1426                                );
1427                            }
1428                        }
1429                    }
1430                    bynk_syntax::ast::TypeBody::Refined { .. }
1431                    | bynk_syntax::ast::TypeBody::Opaque { .. } => {
1432                        for (label, sig) in [
1433                            (
1434                                "of",
1435                                format!("of(value) -> Result[{receiver}, ValidationError]"),
1436                            ),
1437                            ("unsafe", format!("unsafe(value) -> {receiver}")),
1438                        ] {
1439                            if seen.insert(label.to_string()) {
1440                                out.push(Completion::item(
1441                                    label,
1442                                    CompletionKind::Member,
1443                                    Some(sig),
1444                                ));
1445                            }
1446                        }
1447                    }
1448                    // A plain alias (`type X = Int`) or a record has no
1449                    // name-receiver members — record fields are value-receiver
1450                    // (slice 3).
1451                    _ => {}
1452                },
1453                CommonsItem::Capability(c) if c.name.name == receiver && c.derived.is_some() => {
1454                    derived_base = c.derived.as_ref().map(|d| d.base.name.clone());
1455                }
1456                CommonsItem::Capability(c) if c.name.name == receiver => {
1457                    for op in &c.ops {
1458                        if seen.insert(op.name.name.clone()) {
1459                            // Typed signature (params + return), the same
1460                            // `type_ref_str` rendering hover/signature help use —
1461                            // not bare param names (slice 5 detail polish).
1462                            let params = op
1463                                .params
1464                                .iter()
1465                                .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1466                                .collect::<Vec<_>>()
1467                                .join(", ");
1468                            // #926: `[T, …]` type parameters on the op itself.
1469                            let type_params = if op.type_params.is_empty() {
1470                                String::new()
1471                            } else {
1472                                let names: Vec<&str> = op
1473                                    .type_params
1474                                    .iter()
1475                                    .map(|tp| tp.name.name.as_str())
1476                                    .collect();
1477                                format!("[{}]", names.join(", "))
1478                            };
1479                            out.push(Completion::item(
1480                                op.name.name.clone(),
1481                                CompletionKind::Member,
1482                                Some(format!(
1483                                    "{}{type_params}({params}) -> {} — operation of `{receiver}`",
1484                                    op.name.name,
1485                                    type_ref_str(&op.return_type)
1486                                )),
1487                            ));
1488                        }
1489                    }
1490                }
1491                _ => {}
1492            }
1493        }
1494    });
1495    if let Some(base) = derived_base.filter(|b| !followed.contains(b)) {
1496        for c in member_candidates_from(&base, doc_text, files, followed) {
1497            if seen.insert(c.label.clone()) {
1498                out.push(c);
1499            }
1500        }
1501    }
1502    out
1503}
1504
1505// -- Positional candidate sources (static registries + project parse) --
1506
1507/// Built-in type names not declared in any parseable source. Base and generic
1508/// types from the language core; collection types from `builtin_names`. Docs
1509/// are drawn from the `keywords` registry where present (one source of truth).
1510const BUILTIN_TYPES: &[&str] = &[
1511    bynk_check::builtin_names::types::INT,
1512    "Bool",
1513    bynk_check::builtin_names::types::FLOAT,
1514    "String",
1515    "Option",
1516    "Result",
1517    "Effect",
1518    bynk_check::builtin_names::types::LIST,
1519    bynk_check::builtin_names::types::MAP,
1520];
1521
1522/// Declaration snippets (`CompletionItemKind::SNIPPET`), as LSP snippet bodies.
1523/// `pub` so `tests/scaffolds_compile.rs` (ADR 0157) can enumerate them.
1524pub const SNIPPETS: &[(&str, &str)] = &[
1525    // -- Units --
1526    ("context", "context ${1:name} {\n\t$0\n}"),
1527    ("commons", "commons ${1:my.lib}\n\n$0"),
1528    (
1529        "adapter",
1530        "adapter ${1:name} {\n\tbinding \"${2:./module}\"\n\t$0\n}",
1531    ),
1532    // -- Unit-header clauses --
1533    ("uses", "uses ${1:module}"),
1534    ("consumes", "consumes ${1:bynk} { ${2:Random} }"),
1535    // -- Types --
1536    (
1537        "type record",
1538        "type ${1:Name} = {\n\t${2:field}: ${3:Int},\n}",
1539    ),
1540    ("type enum", "type ${1:Name} = enum {\n\t${2:Variant},\n}"),
1541    (
1542        "type refined",
1543        "type ${1:Name} = ${2:String} where ${3:MinLength(1)}",
1544    ),
1545    (
1546        "type opaque",
1547        "type ${1:Name} = opaque ${2:Int} where ${3:NonNegative}",
1548    ),
1549    // -- Functions --
1550    (
1551        "fn",
1552        "fn ${1:name}(${2:x}: ${3:Int}) -> ${4:Int} {\n\t$0\n}",
1553    ),
1554    (
1555        "fn contract",
1556        "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}",
1557    ),
1558    // -- Capabilities & providers --
1559    (
1560        "capability",
1561        "capability ${1:Name} {\n\tfn ${2:op}() -> Effect[${3:Unit}]\n}",
1562    ),
1563    (
1564        "provides",
1565        "provides ${1:Cap} = ${2:Impl} {\n\tfn ${3:op}(${4}) -> Effect[${5:()}] {\n\t\tEffect.pure(${6:()})\n\t}\n}",
1566    ),
1567    // -- Actors & agents --
1568    (
1569        "actor",
1570        "actor ${1:Name} { auth = ${2:Bearer(secret = \"AUTH_JWT_SECRET\")}, identity = ${3:UserId} }",
1571    ),
1572    (
1573        "agent",
1574        "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}",
1575    ),
1576    // -- Services & handlers --
1577    (
1578        "service",
1579        "service ${1:name} {\n\ton call(${2}) -> Effect[${3:Unit}] {\n\t\t$0\n\t}\n}",
1580    ),
1581    ("on call", "on call(${1}) -> Effect[${2:Unit}] {\n\t$0\n}"),
1582    (
1583        "on http",
1584        "on ${1|GET,POST,PUT,DELETE,PATCH|}(\"${2:/path}\") (${3:body}: ${4:Req}) -> Effect[HttpResult[${5:Res}]] given ${6:Cap} {\n\t$0\n}",
1585    ),
1586    (
1587        "on cron",
1588        "on schedule(\"${1:0 * * * *}\") () -> Effect[Result[(), String]] {\n\t$0\n\tOk(())\n}",
1589    ),
1590    // -- Tests --
1591    (
1592        "suite",
1593        "suite ${1:target}\n\ncase \"${2:it works}\" {\n\tlet ${3:actual} = ${4:0}\n\texpect ${5:actual == 0}\n}",
1594    ),
1595    (
1596        "property",
1597        "property \"${1:invariant holds}\" {\n\tfor all ${2:x}: ${3:Int} {\n\t\texpect ${4:x == x}\n\t}\n}",
1598    ),
1599    // #2137: move the test harness's clock (a `case` body statement).
1600    ("advance Clock by", "advance Clock by ${1:15.minutes}"),
1601];
1602
1603/// The value constructors offered at expression position (ADR 0093 D3) — the
1604/// closed set of `Result`/`Option` variant constructors and the boolean
1605/// literals. A value expression can begin with any of these; their docs reuse
1606/// the `keywords` registry (one source of truth).
1607const CONSTRUCTORS: &[&str] = &["Ok", "Err", "Some", "None", "true", "false"];
1608
1609/// Expression-position candidates: the value constructors plus in-scope type
1610/// names (the entry to a static call like `Int.parse` or a record construction
1611/// like `Order { … }`). In-scope values — locals/params, and from slice 3 free
1612/// functions — are appended by the handler, which owns the analysis cache, so
1613/// they are not produced here (ADR 0093 D3).
1614fn expression_candidates(
1615    doc_text: &str,
1616    files: Option<&HashMap<PathBuf, String>>,
1617) -> Vec<Completion> {
1618    let mut out: Vec<Completion> = CONSTRUCTORS
1619        .iter()
1620        .map(|&name| {
1621            Completion::item(
1622                name,
1623                CompletionKind::Constructor,
1624                keyword_doc(name).map(str::to_string),
1625            )
1626        })
1627        .collect();
1628    // Type names are valid here too (static receiver / record construction); the
1629    // `Type.` member context (slice 1) takes over once the user types the dot.
1630    out.extend(type_candidates(doc_text, files));
1631    // In-scope free functions — the current unit's own `fn`s and the combinators
1632    // of every `uses`-imported module (project + stdlib) — ADR 0093 D3 / G5 —
1633    // plus their assets (#1883), in scope by the same rule.
1634    out.extend(free_function_candidates(doc_text, files));
1635    out
1636}
1637
1638/// A unit's top-level items and its `uses` clauses, for the kinds that carry
1639/// free functions. Service/other units contribute neither.
1640fn unit_items_and_uses(unit: &SourceUnit) -> (&[CommonsItem], &[UsesDecl]) {
1641    match unit {
1642        SourceUnit::Commons(c) => (&c.items, &c.uses),
1643        SourceUnit::Context(c) => (&c.items, &c.uses),
1644        SourceUnit::Adapter(a) => (&a.items, &a.uses),
1645        _ => (&[], &[]),
1646    }
1647}
1648
1649/// The qualified name of the unit the cursor's document declares, via a recovery
1650/// parse (the header survives a mid-edit body). `None` for a headerless fragment
1651/// that names no unit.
1652fn current_unit_name(doc_text: &str) -> Option<String> {
1653    let tokens = lexer::tokenize(doc_text).ok()?;
1654    let (unit, _errs) = parser::parse_unit_with_recovery(&tokens, doc_text);
1655    Some(unit?.name().joined())
1656}
1657
1658/// Standard-library S3 (#1963): whether a first-party item path is experimental.
1659fn experimental(path: &str) -> bool {
1660    bynk_check::firstparty::stability_of(path)
1661        == Some(bynk_check::firstparty::Stability::Experimental)
1662}
1663
1664/// The completion-detail suffix that marks an experimental item, so it is
1665/// visible before it is used.
1666fn stability_suffix(experimental: bool) -> &'static str {
1667    if experimental {
1668        " — experimental"
1669    } else {
1670        ""
1671    }
1672}
1673
1674/// Render a free function's signature for the completion detail, the same way
1675/// hover and signature help do (`symbols::type_ref_str`) — one format, never
1676/// divergent. Mirrors signature help: no generic-parameter list.
1677fn free_fn_signature(name: &str, f: &bynk_syntax::ast::FnDecl) -> String {
1678    let params = f
1679        .params
1680        .iter()
1681        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1682        .collect::<Vec<_>>()
1683        .join(", ");
1684    format!("{name}({params}) -> {}", type_ref_str(&f.return_type))
1685}
1686
1687/// Free-function candidates at expression position: the current unit's own
1688/// top-level `fn`s plus the free `fn`s of every `uses`-imported module (project
1689/// commons and the embedded stdlib). Gated on the `uses` set so a combinator is
1690/// offered only where it is actually in scope (ADR 0093 D3 / G5). The same
1691/// units' `asset` declarations (#1883) ride the same pass and the same gate —
1692/// an asset name resolves exactly where a free function's would.
1693fn free_function_candidates(
1694    doc_text: &str,
1695    files: Option<&HashMap<PathBuf, String>>,
1696) -> Vec<Completion> {
1697    let Some(current) = current_unit_name(doc_text) else {
1698        return Vec::new();
1699    };
1700    // One parse pass: collect each unit's name, its free `fn`s (name + signature),
1701    // its assets (name + declaration), and its `uses` targets.
1702    struct UnitFns {
1703        name: String,
1704        fns: Vec<(String, String)>,
1705        assets: Vec<(String, String, Option<String>)>,
1706        embeds: Vec<(String, String, Option<String>)>,
1707        uses: Vec<String>,
1708    }
1709    let mut units: Vec<UnitFns> = Vec::new();
1710    for_each_unit(doc_text, files, |unit| {
1711        let (items, uses) = unit_items_and_uses(unit);
1712        let fns = items
1713            .iter()
1714            .filter_map(|it| match it {
1715                CommonsItem::Fn(f) => match &f.name {
1716                    FnName::Free(id) => Some((id.name.clone(), free_fn_signature(&id.name, f))),
1717                    FnName::Method { .. } => None,
1718                },
1719                _ => None,
1720            })
1721            .collect();
1722        let assets = items
1723            .iter()
1724            .filter_map(|it| match it {
1725                CommonsItem::Asset(a) => Some((
1726                    a.name.name.clone(),
1727                    format!(
1728                        "asset {} = \"{}\"{}",
1729                        a.name.name,
1730                        bynk_fmt::escape_string(&a.path),
1731                        a.at.as_ref()
1732                            .map(|(p, _)| format!(" at \"{}\"", bynk_fmt::escape_string(p)))
1733                            .unwrap_or_default()
1734                    ),
1735                    a.documentation.as_deref().map(str::to_string),
1736                )),
1737                _ => None,
1738            })
1739            .collect();
1740        // #2387: an embed rides the same pass and gate as an asset.
1741        let embeds = items
1742            .iter()
1743            .filter_map(|it| match it {
1744                CommonsItem::Embed(e) => Some((
1745                    e.name.name.clone(),
1746                    format!(
1747                        "embed {} = \"{}\"",
1748                        e.name.name,
1749                        bynk_fmt::escape_string(&e.path)
1750                    ),
1751                    e.documentation.as_deref().map(str::to_string),
1752                )),
1753                _ => None,
1754            })
1755            .collect();
1756        units.push(UnitFns {
1757            name: unit.name().joined(),
1758            fns,
1759            assets,
1760            embeds,
1761            uses: uses.iter().map(|u| u.target.joined()).collect(),
1762        });
1763    });
1764    // The import scope: the `uses` targets of every unit sharing the current name
1765    // (a unit may span files, so union them).
1766    let mut imported: BTreeSet<String> = BTreeSet::new();
1767    for u in &units {
1768        if u.name == current {
1769            imported.extend(u.uses.iter().cloned());
1770        }
1771    }
1772    // Offer the current unit's own fns and the fns of each imported module.
1773    let mut out: Vec<Completion> = Vec::new();
1774    let mut seen: BTreeSet<String> = BTreeSet::new();
1775    for u in &units {
1776        let own = u.name == current;
1777        if !own && !imported.contains(&u.name) {
1778            continue;
1779        }
1780        let origin = if own { "this unit" } else { u.name.as_str() };
1781        for (name, sig) in &u.fns {
1782            if seen.insert(name.clone()) {
1783                let marked = !own && experimental(&format!("{}.{name}", u.name));
1784                out.push(Completion::item(
1785                    name.clone(),
1786                    CompletionKind::Function,
1787                    Some(format!("{sig} — `{origin}`{}", stability_suffix(marked))),
1788                ));
1789            }
1790        }
1791        for (name, decl, doc) in u.assets.iter().chain(&u.embeds) {
1792            if seen.insert(name.clone()) {
1793                out.push(
1794                    Completion::item(
1795                        name.clone(),
1796                        CompletionKind::Constant,
1797                        Some(format!("{decl} — `{origin}`")),
1798                    )
1799                    .with_doc(doc.as_deref()),
1800                );
1801            }
1802        }
1803    }
1804    out
1805}
1806
1807/// The one-line doc for a name in the `keywords` registry, if present.
1808/// `pub` so hover's bare-keyword fallback (ADR 0156) can reuse it —
1809/// completion and hover render the same doc, never a parallel copy.
1810pub fn keyword_doc(word: &str) -> Option<&'static str> {
1811    keywords::KEYWORDS
1812        .iter()
1813        .find(|k| k.word == word)
1814        .map(|k| k.meaning)
1815}
1816
1817/// Type-position candidates: built-in types (with registry docs), then every
1818/// `type` declaration found in the project sources and the embedded `bynk`
1819/// surface (so the transparent surface types `Uuid`/`Method`/… come for free).
1820fn type_candidates(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<Completion> {
1821    let mut out: Vec<Completion> = Vec::new();
1822    let mut seen: BTreeSet<String> = BTreeSet::new();
1823    for &name in BUILTIN_TYPES {
1824        if seen.insert(name.to_string()) {
1825            let detail = keyword_doc(name)
1826                .map(str::to_string)
1827                .or_else(|| match name {
1828                    "List" => Some("The built-in list type, `List[T]`.".to_string()),
1829                    "Map" => Some("The built-in map type, `Map[K, V]`.".to_string()),
1830                    _ => Some("built-in type".to_string()),
1831                });
1832            out.push(Completion::item(name, CompletionKind::Type, detail));
1833        }
1834    }
1835    for_each_unit(doc_text, files, |unit| {
1836        let items = match unit {
1837            SourceUnit::Commons(c) => &c.items,
1838            SourceUnit::Context(c) => &c.items,
1839            SourceUnit::Adapter(a) => &a.items,
1840            _ => return,
1841        };
1842        for item in items {
1843            if let CommonsItem::Type(t) = item
1844                && seen.insert(t.name.name.clone())
1845            {
1846                out.push(Completion::item(
1847                    t.name.name.clone(),
1848                    CompletionKind::Type,
1849                    Some("type".to_string()),
1850                ));
1851            }
1852        }
1853    });
1854    out
1855}
1856
1857/// Keyword-position candidates: the lowercase-initial reserved keywords (the
1858/// declaration/statement words — uppercase type/value names like `Int`/`Some`
1859/// belong to type/expression position) with their registry docs, plus the
1860/// declaration snippets.
1861fn keyword_and_snippet_candidates() -> Vec<Completion> {
1862    let mut out: Vec<Completion> = keywords::KEYWORDS
1863        .iter()
1864        .filter(|k| k.word.chars().next().is_some_and(char::is_lowercase))
1865        .map(|k| Completion::item(k.word, CompletionKind::Keyword, Some(k.meaning.to_string())))
1866        .collect();
1867    for &(label, body) in SNIPPETS {
1868        out.push(Completion::snippet(label, body));
1869    }
1870    out
1871}
1872
1873// -- Enumeration (parse project sources + the embedded `bynk` surface) --
1874
1875/// Lex + recovery-parse one source, yielding its primary [`SourceUnit`]
1876/// (`None` when nothing parses — an empty or header-broken file).
1877fn parse_source_unit(src: &str) -> Option<SourceUnit> {
1878    let tokens = lexer::tokenize(src).ok()?;
1879    parser::parse_unit_with_recovery(&tokens, src).0
1880}
1881
1882/// The embedded first-party surface, parsed **once** for the whole process.
1883/// These are compile-time `include_str!` constants, so their parse is fixed;
1884/// re-lexing and re-parsing all five on every keystroke-driven request was
1885/// pure waste (#733). The `bynk` surface, the `bynk.cloudflare` platform
1886/// adapter, and the stdlib commons (`bynk.list`/`bynk.map`/`bynk.string`) whose
1887/// free fns are enumerable for `uses`-imported completion (G5) and signature
1888/// help. Harmless to the other contexts — the commons declare only `fn`s (no
1889/// types/capabilities) and are never a `consumes` target.
1890static EMBEDDED_UNITS: LazyLock<Vec<Arc<SourceUnit>>> = LazyLock::new(|| {
1891    // The single first-party source list (`bynk-check::firstparty`), so a new
1892    // first-party commons is completion-visible without a second edit here
1893    // (#901 — `bynk.locale`/`bynk.locale.types` were missing from this copy).
1894    bynk_check::firstparty::FIRSTPARTY_SOURCES
1895        .iter()
1896        .filter_map(|(_, src)| parse_source_unit(src).map(Arc::new))
1897        .collect()
1898});
1899
1900/// S4a (#1966): the first-party unit `unit` (`bynk.cloudflare`), from the
1901/// embedded surface, when it declares capability `cap`: what a derived
1902/// capability's hover renders its base's operations from.
1903pub(crate) fn embedded_capability(
1904    unit: &str,
1905    cap: &str,
1906) -> Option<bynk_syntax::ast::CapabilityDecl> {
1907    EMBEDDED_UNITS.iter().find_map(|u| {
1908        if u.name().joined() != unit {
1909            return None;
1910        }
1911        let SourceUnit::Adapter(a) = u.as_ref() else {
1912            return None;
1913        };
1914        a.items.iter().find_map(|item| match item {
1915            CommonsItem::Capability(c) if c.name.name == cap => Some(c.clone()),
1916            _ => None,
1917        })
1918    })
1919}
1920
1921/// The parsed unit for a project file's already-read `content`. P8.4
1922/// (#1515): served from `bynk_project::parse_cache`'s own shared, durable
1923/// cache (replacing this module's former `PROJECT_UNIT_CACHE` — one cache
1924/// of the same fact, not two independently-invalidated ones, per ADR
1925/// 0413/[DECISION C]) when the cached/fresh **strict** parse is clean; a
1926/// syntax error elsewhere in the project falls back to a local, uncached
1927/// recovery-parse for that one file ([DECISION E], `bynk-project::
1928/// parse_cache`'s own doc comment) — for clean source (the overwhelming
1929/// common case) the strict and recovery parsers produce the identical AST,
1930/// since there is nothing to recover from.
1931///
1932/// PR #1520's own bot review (finding #4): `parser::
1933/// parse_units_with_warnings_from` (what the shared cache stores) always
1934/// converts an empty unit list into `Err("bynk.parse.unexpected_eof")`
1935/// (`bynk-syntax/src/parser.rs`'s own `parse_units_with_drain_check_from`)
1936/// — `Ok` with zero units is not a real case this parser produces, so
1937/// `units.first()` alone says what is meant without an `is_empty()` guard
1938/// that would otherwise route a (nonexistent) clean-but-empty parse down
1939/// the same uncached recovery-parse fallback as a genuine syntax error,
1940/// permanently, on every request.
1941fn cached_project_unit(path: &Path, content: &str) -> Option<Arc<SourceUnit>> {
1942    let (_file_id, result) = bynk_project::parse_cache::cached_parse(path, content);
1943    match result {
1944        Ok((units, _warnings)) => units.first().cloned().map(Arc::new),
1945        Err(_) => parse_source_unit(content).map(Arc::new),
1946    }
1947}
1948
1949/// Parse every project unit, plus the embedded first-party adapters (the
1950/// `bynk` surface and the `bynk.cloudflare` platform adapter), and call `f`
1951/// for each. Recovery parsing tolerates the in-progress edit at the cursor.
1952///
1953/// The embedded surface is parsed once (`EMBEDDED_UNITS`) and the project's
1954/// other files are served from a per-file parse cache (`cached_project_unit`),
1955/// keyed on content rather than disk metadata (content-ownership track,
1956/// #1086, slice 0+1) — `files` is a pre-read `(path, content)` map the caller
1957/// (`bynk-lsp`) built by overlaying every open buffer over a disk sweep, so
1958/// an unsaved edit to file A is visible here exactly like a saved one. Only
1959/// `doc_text` — the buffer under the cursor, which changes per keystroke — is
1960/// parsed fresh each call (#733), never looked up in `files` (the caller
1961/// excludes the cursor's own file from `files` for exactly this reason).
1962/// Ordering is preserved (embedded, then the buffer, then the other files) so
1963/// a callback's first-name-wins dedup still prefers the live buffer.
1964pub fn for_each_unit(
1965    doc_text: &str,
1966    files: Option<&HashMap<PathBuf, String>>,
1967    mut f: impl FnMut(&SourceUnit),
1968) {
1969    for unit in EMBEDDED_UNITS.iter() {
1970        f(unit);
1971    }
1972    if let Some(unit) = parse_source_unit(doc_text) {
1973        f(&unit);
1974    }
1975    // Slice A: the project's files come from the compiler's own discovery
1976    // (`bynk_ide::discover_files`) — every `include` root, `exclude` honoured.
1977    // This used to walk a single directory by hand, so completion could not see
1978    // a second root and swept `out`/`node_modules` if they sat beneath it.
1979    if let Some(content) = files {
1980        for (path, text) in content {
1981            if let Some(unit) = cached_project_unit(path, text) {
1982                f(&unit);
1983            }
1984        }
1985    }
1986}
1987
1988/// #2223: `files` split into the project's own files and its dependencies'
1989/// files (each with its package), when the project has dependencies. A
1990/// dependency's units are never reachable by their bare names, so completion
1991/// that offers unit names reads only the project's own files, and offers a
1992/// dependency's units as `<alias>.<unit>`.
1993fn split_dependency_files<'f>(
1994    files: Option<&'f HashMap<PathBuf, String>>,
1995    project_root: Option<&Path>,
1996) -> SplitFiles<'f> {
1997    let Some(content) = files else {
1998        return SplitFiles::default();
1999    };
2000    let view = match project_root {
2001        Some(root) => crate::packages::DependencyView::for_root(root, content),
2002        None => crate::packages::DependencyView::from_files(content),
2003    };
2004    let Some(view) = view else {
2005        return SplitFiles {
2006            own: Some(Cow::Borrowed(content)),
2007            deps: Vec::new(),
2008        };
2009    };
2010    let mut own = HashMap::new();
2011    let mut deps = Vec::new();
2012    let mut keys: Vec<&PathBuf> = content.keys().collect();
2013    keys.sort();
2014    for path in keys {
2015        let text = &content[path];
2016        match view.package_of(path) {
2017            None => {
2018                own.insert(path.clone(), text.clone());
2019            }
2020            Some(id) => {
2021                if let Some(unit) = cached_project_unit(path, text) {
2022                    for alias in view.aliases_of(id) {
2023                        let declared = unit.name().joined();
2024                        if view.exports(id, &declared) {
2025                            deps.push((format!("{alias}.{declared}"), (*unit).clone()));
2026                        }
2027                    }
2028                }
2029            }
2030        }
2031    }
2032    SplitFiles {
2033        own: Some(Cow::Owned(own)),
2034        deps,
2035    }
2036}
2037
2038/// [`split_dependency_files`]'s result: the project's own files (borrowed
2039/// unchanged when the project has no dependencies), and each reachable
2040/// dependency unit with the `<alias>.<unit>` label it's written as.
2041#[derive(Default)]
2042struct SplitFiles<'f> {
2043    own: Option<Cow<'f, HashMap<PathBuf, String>>>,
2044    deps: Vec<(String, SourceUnit)>,
2045}
2046
2047impl SplitFiles<'_> {
2048    fn own(&self) -> Option<&HashMap<PathBuf, String>> {
2049        self.own.as_deref()
2050    }
2051}
2052
2053/// #2223: commons a `uses` can name — the project's own, then each
2054/// dependency's exported commons as `<alias>.<unit>`.
2055fn usable_commons(doc_text: &str, split: &SplitFiles<'_>) -> Vec<Completion> {
2056    let mut seen: BTreeSet<String> = BTreeSet::new();
2057    let mut out: Vec<Completion> = Vec::new();
2058    for_each_unit(doc_text, split.own(), |unit| {
2059        if let SourceUnit::Commons(c) = unit {
2060            let name = c.name.joined();
2061            if seen.insert(name.clone()) {
2062                out.push(Completion::item(
2063                    name,
2064                    CompletionKind::Unit,
2065                    Some("commons".to_string()),
2066                ));
2067            }
2068        }
2069    });
2070    for (label, unit) in &split.deps {
2071        if matches!(unit, SourceUnit::Commons(_)) && seen.insert(label.clone()) {
2072            out.push(Completion::item(
2073                label,
2074                CompletionKind::Unit,
2075                Some("commons from a dependency".to_string()),
2076            ));
2077        }
2078    }
2079    out
2080}
2081
2082/// Consumable unit names: contexts and adapters (plus `bynk`), deduplicated.
2083/// #2223: a dependency's adapters are offered as `<alias>.<unit>`, its
2084/// contexts never (a context is never shared).
2085fn consumable_units(doc_text: &str, split: &SplitFiles<'_>) -> Vec<Completion> {
2086    let files = split.own();
2087    let mut seen: BTreeSet<String> = BTreeSet::new();
2088    let mut out: Vec<Completion> = Vec::new();
2089    for (label, unit) in &split.deps {
2090        if matches!(unit, SourceUnit::Adapter(_)) && seen.insert(label.clone()) {
2091            out.push(Completion::item(
2092                label.clone(),
2093                CompletionKind::Unit,
2094                Some("adapter from a dependency".to_string()),
2095            ));
2096        }
2097    }
2098    for_each_unit(doc_text, files, |unit| {
2099        let (name, kind) = match unit {
2100            SourceUnit::Context(c) => (c.name.joined(), "context"),
2101            SourceUnit::Adapter(a) => (a.name.joined(), "adapter"),
2102            _ => return,
2103        };
2104        if seen.insert(name.clone()) {
2105            out.push(Completion::item(
2106                name,
2107                CompletionKind::Unit,
2108                Some(kind.to_string()),
2109            ));
2110        }
2111    });
2112    out
2113}
2114
2115/// The capability names a unit `exports capability`.
2116///
2117/// Finding #62: a first-wins guard, for the same buffer-vs-stale-disk-copy
2118/// reason as [`record_field_names`] — the `BTreeSet` still dedups a single
2119/// matching unit's own (possibly repeated) export clauses.
2120fn capabilities_of_unit(unit: &str, doc_text: &str, split: &SplitFiles<'_>) -> Vec<String> {
2121    // #2223: `<alias>.<unit>` names a dependency's adapter, declared there
2122    // under its own name.
2123    if let Some((_, dep)) = split.deps.iter().find(|(label, _)| label == unit) {
2124        let mut out: BTreeSet<String> = BTreeSet::new();
2125        if let SourceUnit::Adapter(a) = dep {
2126            for clause in &a.exports {
2127                if clause.kind == ExportKind::Capability {
2128                    for n in clause.names.iter().map(|e| &e.name) {
2129                        out.insert(n.name.clone());
2130                    }
2131                }
2132            }
2133        }
2134        return out.into_iter().collect();
2135    }
2136    let files = split.own();
2137    let mut out: BTreeSet<String> = BTreeSet::new();
2138    let mut found = false;
2139    for_each_unit(doc_text, files, |u| {
2140        if found {
2141            return;
2142        }
2143        let (name, exports) = match u {
2144            SourceUnit::Context(c) => (c.name.joined(), &c.exports),
2145            SourceUnit::Adapter(a) => (a.name.joined(), &a.exports),
2146            _ => return,
2147        };
2148        if name != unit {
2149            return;
2150        }
2151        found = true;
2152        for clause in exports {
2153            if clause.kind == ExportKind::Capability {
2154                for n in clause.names.iter().map(|e| &e.name) {
2155                    out.insert(n.name.clone());
2156                }
2157            }
2158        }
2159    });
2160    out.into_iter().collect()
2161}
2162
2163/// Capabilities in scope for a `given` clause in the current document: locally
2164/// declared capabilities, bare names flattened by a braced `consumes`, and
2165/// `U.Cap` for each whole-unit `consumes U`.
2166fn in_scope_capabilities(doc_text: &str, split: &SplitFiles<'_>) -> Vec<Completion> {
2167    let mut labels: BTreeSet<String> = BTreeSet::new();
2168    let Ok(tokens) = lexer::tokenize(doc_text) else {
2169        return Vec::new();
2170    };
2171    let (Some(unit), _errs) = parser::parse_unit_with_recovery(&tokens, doc_text) else {
2172        return Vec::new();
2173    };
2174    let (items, consumes) = match &unit {
2175        SourceUnit::Context(c) => (&c.items, &c.consumes),
2176        SourceUnit::Adapter(a) => (&a.items, &EMPTY_CONSUMES),
2177        _ => return Vec::new(),
2178    };
2179    // Locally declared capabilities.
2180    for item in items {
2181        if let bynk_syntax::ast::CommonsItem::Capability(c) = item {
2182            labels.insert(c.name.name.clone());
2183        }
2184    }
2185    // Consumed capabilities: flattened bare names, or qualified `U.Cap`.
2186    for c in consumes {
2187        let unit_name = c.target.joined();
2188        match &c.selected {
2189            Some(names) => {
2190                for n in names {
2191                    labels.insert(n.name.clone());
2192                }
2193            }
2194            None => {
2195                let prefix = c
2196                    .alias
2197                    .as_ref()
2198                    .map(|a| a.name.clone())
2199                    .unwrap_or_else(|| unit_name.clone());
2200                for cap in capabilities_of_unit(&unit_name, doc_text, split) {
2201                    labels.insert(format!("{prefix}.{cap}"));
2202                }
2203            }
2204        }
2205    }
2206    labels
2207        .into_iter()
2208        .map(|label| {
2209            Completion::item(
2210                label,
2211                CompletionKind::Capability,
2212                Some("capability in scope".to_string()),
2213            )
2214        })
2215        .collect()
2216}
2217
2218// -- Value-receiver `.method`/`.field` (slice 3, ADR 0063) --
2219
2220/// If the cursor (byte `offset` into `text`) sits just after a **lowercase**
2221/// `receiver.`(`partial`) — a *value* receiver — return the buffer **rewritten**
2222/// so the receiver is a complete expression (the trailing `.partial` dropped,
2223/// so the file parses) and the byte offset of the receiver to type. Returns
2224/// `None` for an uppercase name receiver (slice 2), a decimal `1.`, or a
2225/// `.`-qualified segment.
2226///
2227/// The rewrite is the spike's fix for the mid-edit parse: a bare `email.`
2228/// cascades and loses the receiver, but `email` (dot dropped) types cleanly.
2229pub fn value_receiver_rewrite(text: &str, offset: usize) -> Option<(String, usize)> {
2230    let prefix = text.get(..offset)?;
2231    let head = prefix
2232        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
2233        .strip_suffix('.')?;
2234    let (recv, start) = ident_ending_at(head, head.len())?;
2235    let first = recv.chars().next()?;
2236    if !(first.is_ascii_lowercase() || first == '_') {
2237        return None; // uppercase = name receiver (slice 2); a digit = a decimal
2238    }
2239    if head[..start].ends_with('.') {
2240        return None; // a `.`-qualified segment, not a bare value receiver
2241    }
2242    let dot = head.len(); // the receiver ends here; the dot was the next byte
2243    let rewritten = format!("{}{}", &text[..dot], &text[offset..]);
2244    Some((rewritten, dot.saturating_sub(1)))
2245}
2246
2247/// The identifier run ending at byte `end` in `text` — `(name, start)` — the
2248/// trim-back-to-a-non-identifier-boundary scan shared by every receiver/
2249/// identifier extraction in the LSP: [`value_receiver_rewrite`] above,
2250/// `symbols::receiver_segment_at` (a member's receiver, dot-preceded), and
2251/// `symbols::store_field_kind_at` (a bare receiver's own end offset, no dot).
2252/// Each has different preconditions on what precedes `end`, but the boundary
2253/// scan itself — walk back to the nearest non-identifier char, respecting
2254/// UTF-8 boundaries — is one definition, so it can't drift between them (a
2255/// review flagged the previous three near-identical copies). `None` if
2256/// nothing identifier-shaped precedes `end`.
2257pub fn ident_ending_at(text: &str, end: usize) -> Option<(&str, usize)> {
2258    let before = text.get(..end)?;
2259    let start = before
2260        .char_indices()
2261        .rfind(|&(_, c)| !(c.is_alphanumeric() || c == '_'))
2262        .map_or(0, |(i, c)| i + c.len_utf8());
2263    let name = &before[start..];
2264    (!name.is_empty()).then_some((name, start))
2265}
2266
2267/// The members of a typed value receiver: the built-in kernel methods of its
2268/// type (from the enumerable registry) plus, for a record, its fields.
2269pub fn value_member_candidates(
2270    ty: TyId,
2271    tys: &Types,
2272    doc_text: &str,
2273    files: Option<&HashMap<PathBuf, String>>,
2274) -> Vec<Completion> {
2275    let mut out: Vec<Completion> = kernel_methods::methods_for(ty, tys)
2276        .iter()
2277        .map(|km| {
2278            Completion::item(
2279                km.name,
2280                CompletionKind::Member,
2281                Some(km.signature.to_string()),
2282            )
2283        })
2284        .collect();
2285    // An asset's one field (#1883).
2286    if matches!(&*tys.get(ty), Ty::Asset) {
2287        out.push(Completion::item(
2288            "url",
2289            CompletionKind::Field,
2290            Some("String — the URL the platform serves the asset at".to_string()),
2291        ));
2292    }
2293    // Record fields — resolve the receiver's named type to its declaration.
2294    if let Ty::Named { name, .. } = &*tys.get(ty) {
2295        let mut seen: BTreeSet<String> = BTreeSet::new();
2296        for_each_unit(doc_text, files, |unit| {
2297            let items = match unit {
2298                SourceUnit::Commons(c) => &c.items,
2299                SourceUnit::Context(c) => &c.items,
2300                SourceUnit::Adapter(a) => &a.items,
2301                _ => return,
2302            };
2303            for item in items {
2304                if let CommonsItem::Type(t) = item
2305                    && &t.name.name == name
2306                    && let TypeBody::Record(r) = &t.body
2307                {
2308                    for f in &r.fields {
2309                        if seen.insert(f.name.name.clone()) {
2310                            out.push(
2311                                Completion::item(
2312                                    f.name.name.clone(),
2313                                    CompletionKind::Field,
2314                                    Some(format!("field of `{name}`")),
2315                                )
2316                                .with_doc(f.documentation.as_deref()),
2317                            );
2318                        }
2319                    }
2320                }
2321            }
2322        });
2323    }
2324    out
2325}
2326
2327/// #2138: `id` after `run.` in a `case` body that hasn't bound its own
2328/// `run` — the test run's identity. `rewritten`/`recv_offset` are
2329/// [`value_receiver_rewrite`]'s output (`recv_offset` is the receiver's last
2330/// byte). Empty anywhere else.
2331pub fn run_id_member_candidates(rewritten: &str, recv_offset: usize) -> Vec<Completion> {
2332    let Some((name, start)) = ident_ending_at(rewritten, recv_offset + 1) else {
2333        return Vec::new();
2334    };
2335    if name != "run" || !crate::symbols::is_test_run_receiver(rewritten, start) {
2336        return Vec::new();
2337    }
2338    vec![
2339        Completion::item("id", CompletionKind::Field, Some("String".to_string()))
2340            .with_doc(Some(crate::symbols::RUN_ID_DOC)),
2341    ]
2342}
2343
2344/// #596: the entry ops (and, for a `Map`, the `.entries`/`.keys`/`.values`
2345/// query accessors) of a bare `store` field receiver — merged onto
2346/// [`value_member_candidates`] so a store field offers its whole vocabulary,
2347/// not just the `Query` half `kernel_methods::methods_for` covers. A bare
2348/// store `Map` field types (via the checker's ADR 0120 "whole map as a value"
2349/// reading) to plain `Ty::Query`, indistinguishable from an ordinary
2350/// `Query`-typed local — so this reads the receiver's *provenance* instead,
2351/// the same way hover's `describe_store_op_at` does. Empty when the receiver
2352/// isn't a store field of an enclosing agent, or is shadowed by a local.
2353///
2354/// `rewritten`/`recv_offset` are [`value_receiver_rewrite`]'s output; `locals`
2355/// is the current round's locals for the file, best-effort (empty when the
2356/// analysed round doesn't match `rewritten`, in which case the shadowing
2357/// check simply sees no local in scope).
2358pub fn store_field_member_candidates(
2359    rewritten: &str,
2360    recv_offset: usize,
2361    locals: &[LocalBinding],
2362) -> Vec<Completion> {
2363    let Some((kind_head, held)) =
2364        crate::symbols::store_field_kind_at(rewritten, recv_offset + 1, locals)
2365    else {
2366        return Vec::new();
2367    };
2368    let mut out: Vec<Completion> = store_ops::ops_for(&kind_head)
2369        .iter()
2370        .map(|o| {
2371            Completion::item(
2372                o.name,
2373                CompletionKind::Member,
2374                Some(o.signature.to_string()),
2375            )
2376        })
2377        .collect();
2378    if kind_head == "Map" && !held {
2379        out.extend(store_ops::MAP_QUERY_ACCESSORS.iter().map(|a| {
2380            Completion::item(a.name, CompletionKind::Field, Some(a.signature.to_string()))
2381        }));
2382    }
2383    out
2384}
2385
2386static EMPTY_CONSUMES: Vec<bynk_syntax::ast::ConsumesDecl> = Vec::new();
2387
2388#[cfg(test)]
2389mod tests {
2390    use super::*;
2391    use bynk_check::firstparty::BYNK_LIST_SRC;
2392
2393    /// S3 (#1963): an experimental first-party item is marked in its completion
2394    /// detail; nothing ships experimental, so a stable one is unmarked.
2395    #[test]
2396    fn completion_marks_experimental_items() {
2397        assert_eq!(stability_suffix(true), " — experimental");
2398        assert_eq!(stability_suffix(false), "");
2399        assert!(!experimental("bynk.cloudflare.Kv"));
2400        let caps = complete("consumes bynk.cloudflare { ", "context app.probe\n", None);
2401        let kv = caps
2402            .iter()
2403            .find(|c| c.label == "Kv")
2404            .expect("Kv is offered");
2405        assert_eq!(
2406            kv.detail.as_deref(),
2407            Some("capability exported by `bynk.cloudflare`")
2408        );
2409    }
2410
2411    fn labels(line: &str, doc: &str) -> Vec<String> {
2412        complete(line, doc, None)
2413            .into_iter()
2414            .map(|c| c.label)
2415            .collect()
2416    }
2417
2418    #[test]
2419    fn consumes_target_suggests_units_including_bynk() {
2420        // An adapter in the open doc plus the always-available `bynk` surface.
2421        let doc = "adapter tokens {\n  binding \"./b.ts\"\n  capability Jwt { fn f() -> Effect[Int] }\n  provides Jwt = X\n}\n";
2422        let got = labels("  consumes ", doc);
2423        assert!(got.contains(&"bynk".to_string()), "{got:?}");
2424        assert!(got.contains(&"tokens".to_string()), "{got:?}");
2425    }
2426
2427    #[test]
2428    fn consumes_brace_suggests_that_units_capabilities() {
2429        let got = labels("  consumes bynk { ", "context a.b\n");
2430        // The embedded `bynk` surface exports these.
2431        assert!(got.contains(&"Clock".to_string()), "{got:?}");
2432        assert!(got.contains(&"Random".to_string()), "{got:?}");
2433        assert!(got.contains(&"Logger".to_string()), "{got:?}");
2434    }
2435
2436    #[test]
2437    fn given_suggests_local_and_flattened_capabilities() {
2438        let doc = "context a.b\n\
2439                   consumes bynk { Clock }\n\
2440                   capability Local { fn f() -> Effect[Int] }\n\
2441                   service s {\n\
2442                   on call() -> Effect[Int] given Clock {\n\
2443                   1\n\
2444                   }\n\
2445                   }\n";
2446        let got = labels("    on call() -> Effect[Int] given ", doc);
2447        assert!(got.contains(&"Clock".to_string()), "flattened: {got:?}");
2448        assert!(got.contains(&"Local".to_string()), "local: {got:?}");
2449    }
2450
2451    #[test]
2452    fn expression_position_offers_constructors_and_types() {
2453        // ADR 0093 D3/D5: a value position (after `=`) yields every constructor
2454        // keyword and in-scope type names — the entry to a static call or a
2455        // record construction. (Locals/params are appended handler-side, not by
2456        // `complete()`.) Registry-driven over CONSTRUCTORS.
2457        let doc = "commons m {\n  type Order = { id: Int }\n}\n";
2458        let items = complete("  let x = ", doc, None);
2459        for &c in CONSTRUCTORS {
2460            assert!(
2461                find(&items, c, CompletionKind::Constructor).is_some(),
2462                "constructor {c}: {:?}",
2463                items.iter().map(|i| &i.label).collect::<Vec<_>>()
2464            );
2465        }
2466        assert!(
2467            find(&items, "Int", CompletionKind::Type).is_some(),
2468            "builtin type"
2469        );
2470        assert!(
2471            find(&items, "Order", CompletionKind::Type).is_some(),
2472            "project type"
2473        );
2474    }
2475
2476    #[test]
2477    fn value_receiver_and_decimal_are_not_expression_positions() {
2478        // A trailing `x.`/`1.` is a member/decimal context, not an expression
2479        // start — `complete()` yields nothing (the value-receiver path is
2480        // handler-side; see `record_value_and_decimal_receivers_yield_nothing`).
2481        assert!(complete("  let p = q.", "context a.b\n", None).is_empty());
2482        assert!(complete("  let n = 1.", "context a.b\n", None).is_empty());
2483    }
2484
2485    /// Free `fn` names declared in a unit source (registry-driven test helper).
2486    fn free_fn_names(src: &str) -> Vec<String> {
2487        let tokens = lexer::tokenize(src).unwrap();
2488        let (unit, _) = parser::parse_unit_with_recovery(&tokens, src);
2489        let unit = unit.unwrap();
2490        let (items, _) = unit_items_and_uses(&unit);
2491        items
2492            .iter()
2493            .filter_map(|it| match it {
2494                CommonsItem::Fn(f) => match &f.name {
2495                    FnName::Free(id) => Some(id.name.clone()),
2496                    FnName::Method { .. } => None,
2497                },
2498                _ => None,
2499            })
2500            .collect()
2501    }
2502
2503    #[test]
2504    fn free_functions_offered_for_own_unit_and_used_modules() {
2505        // ADR 0093 D3/G5: expression position offers the current unit's own
2506        // free `fn`s and the combinators of every `uses`-imported module.
2507        let doc = "commons app {\n  uses bynk.list\n  fn helper(x: Int) -> Int { x }\n}\n";
2508        let items = complete("  let y = ", doc, None);
2509        // The current unit's own function.
2510        assert!(
2511            find(&items, "helper", CompletionKind::Function).is_some(),
2512            "own fn: {:?}",
2513            items.iter().map(|i| &i.label).collect::<Vec<_>>()
2514        );
2515        // Every combinator of the imported `bynk.list` — registry-driven over the
2516        // embedded source, so a new stdlib combinator must surface or this fails.
2517        for name in free_fn_names(BYNK_LIST_SRC) {
2518            assert!(
2519                find(&items, &name, CompletionKind::Function).is_some(),
2520                "bynk.list.{name}: {:?}",
2521                items.iter().map(|i| &i.label).collect::<Vec<_>>()
2522            );
2523        }
2524        // A module that is not imported does not leak its fns.
2525        assert!(
2526            find(&items, "values", CompletionKind::Function).is_none(),
2527            "bynk.map.values leaked without `uses bynk.map`"
2528        );
2529    }
2530
2531    #[test]
2532    fn locale_functions_offered_when_bynk_locale_is_used() {
2533        // #901: `bynk.locale` was absent from the embedded-source list, so its
2534        // builders/render never surfaced in completion even under `uses
2535        // bynk.locale`. Exercised through the real `complete()` path, not the
2536        // renderer alone.
2537        let doc = "commons app {\n  uses bynk.locale\n}\n";
2538        let items = complete("  let y = ", doc, None);
2539        for name in [
2540            "render",
2541            "message",
2542            "withText",
2543            "withWhole",
2544            "withNum",
2545            "withMoment",
2546        ] {
2547            assert!(
2548                find(&items, name, CompletionKind::Function).is_some(),
2549                "bynk.locale.{name} missing from completion: {:?}",
2550                items.iter().map(|i| &i.label).collect::<Vec<_>>()
2551            );
2552        }
2553    }
2554
2555    #[test]
2556    fn free_functions_require_a_uses_import() {
2557        // Own fns are always in scope; stdlib combinators only with their `uses`.
2558        let doc = "commons app {\n  fn helper(x: Int) -> Int { x }\n}\n";
2559        let items = complete("  let y = ", doc, None);
2560        assert!(find(&items, "helper", CompletionKind::Function).is_some());
2561        for name in ["map", "filter", "reverse"] {
2562            assert!(
2563                find(&items, name, CompletionKind::Function).is_none(),
2564                "bynk.list.{name} offered without `uses bynk.list`"
2565            );
2566        }
2567    }
2568
2569    #[test]
2570    fn assets_offered_by_the_free_function_scope_rule() {
2571        // #1883: an asset name resolves where a free function's would — the
2572        // unit's own assets, and those of a `uses`-imported unit; never one
2573        // the unit does not import.
2574        let doc = "context app.page {\n  uses app.shared\n  asset logo = \"logo.svg\"\n  asset icon = \"favicon.ico\" at \"/favicon.ico\"\n}\n";
2575        let mut files = HashMap::new();
2576        files.insert(
2577            PathBuf::from("/p/shared.bynk"),
2578            "context app.shared {\n  --- The site stylesheet.\n  asset style = \"site.css\"\n}\n"
2579                .to_string(),
2580        );
2581        files.insert(
2582            PathBuf::from("/p/other.bynk"),
2583            "context app.other {\n  asset banner = \"banner.png\"\n}\n".to_string(),
2584        );
2585        let items = complete("  let y = ", doc, Some(&files));
2586        let labels = || items.iter().map(|i| &i.label).collect::<Vec<_>>();
2587        let logo = find(&items, "logo", CompletionKind::Constant);
2588        assert!(logo.is_some(), "own asset: {:?}", labels());
2589        assert_eq!(
2590            logo.unwrap().detail.as_deref(),
2591            Some("asset logo = \"logo.svg\" — `this unit`")
2592        );
2593        assert_eq!(
2594            find(&items, "icon", CompletionKind::Constant).and_then(|c| c.detail.as_deref()),
2595            Some("asset icon = \"favicon.ico\" at \"/favicon.ico\" — `this unit`")
2596        );
2597        let style = find(&items, "style", CompletionKind::Constant);
2598        assert!(style.is_some(), "used unit's asset: {:?}", labels());
2599        assert_eq!(
2600            style.unwrap().documentation.as_deref(),
2601            Some("The site stylesheet.")
2602        );
2603        assert!(
2604            find(&items, "banner", CompletionKind::Constant).is_none(),
2605            "an asset of a unit not in `uses` leaked: {:?}",
2606            labels()
2607        );
2608    }
2609
2610    #[test]
2611    fn embeds_offered_by_the_free_function_scope_rule() {
2612        // #2387: an embed name resolves where a free function's would — the
2613        // unit's own embeds, and those of a `uses`-imported unit; never one
2614        // the unit does not import.
2615        let doc =
2616            "context app.page {\n  uses app.shared\n  embed shell = \"embeds/shell.html\"\n}\n";
2617        let mut files = HashMap::new();
2618        files.insert(
2619            PathBuf::from("/p/shared.bynk"),
2620            "commons app.shared {\n  --| The mail body.\n  embed mail = \"embeds/mail.txt\"\n}\n"
2621                .to_string(),
2622        );
2623        files.insert(
2624            PathBuf::from("/p/other.bynk"),
2625            "context app.other {\n  embed hidden = \"embeds/hidden.txt\"\n}\n".to_string(),
2626        );
2627        let items = complete("  let y = ", doc, Some(&files));
2628        let labels = || items.iter().map(|i| &i.label).collect::<Vec<_>>();
2629        assert_eq!(
2630            find(&items, "shell", CompletionKind::Constant).and_then(|c| c.detail.as_deref()),
2631            Some("embed shell = \"embeds/shell.html\" — `this unit`"),
2632            "own embed: {:?}",
2633            labels()
2634        );
2635        let mail = find(&items, "mail", CompletionKind::Constant);
2636        assert!(mail.is_some(), "used unit's embed: {:?}", labels());
2637        assert_eq!(
2638            mail.unwrap().documentation.as_deref(),
2639            Some("The mail body.")
2640        );
2641        assert!(
2642            find(&items, "hidden", CompletionKind::Constant).is_none(),
2643            "an embed of a unit not in `uses` leaked: {:?}",
2644            labels()
2645        );
2646    }
2647
2648    #[test]
2649    fn asset_member_completion_offers_url() {
2650        // #1883: an asset value's one field.
2651        let asset = TYS.intern(Ty::Asset);
2652        let items = value_member_candidates(asset, &TYS, "context a.b\n", None);
2653        assert!(find(&items, "url", CompletionKind::Field).is_some());
2654    }
2655
2656    #[test]
2657    fn member_completion_reaches_inside_an_interpolation_hole() {
2658        // v0.43: a `Type.`/`Cap.` receiver inside a `\(…)` hole completes just
2659        // as it does in bare expression position — context detection is purely
2660        // lexical, so the surrounding string and `\(` do not interfere.
2661        let doc = "context a.b\n  capability Timer { fn now() -> Effect[Int] }\n";
2662        let in_hole = complete("    \"the time is \\(Timer.", doc, None);
2663        assert!(
2664            find(&in_hole, "now", CompletionKind::Member).is_some(),
2665            "capability op not offered inside a hole: {:?}",
2666            in_hole.iter().map(|c| &c.label).collect::<Vec<_>>()
2667        );
2668        // A built-in static receiver works inside a hole too.
2669        let statics = complete("  \"n=\\(Int.", "context a.b\n", None);
2670        assert!(find(&statics, "parse", CompletionKind::Member).is_some());
2671    }
2672
2673    #[test]
2674    fn consumes_with_as_is_not_a_target_completion() {
2675        // `consumes X as ` is aliasing, not target-name completion.
2676        assert!(!is_consumes_target("consumes platform.time as "));
2677        assert!(is_consumes_target("consumes platform"));
2678    }
2679
2680    fn find<'a>(
2681        items: &'a [Completion],
2682        label: &str,
2683        kind: CompletionKind,
2684    ) -> Option<&'a Completion> {
2685        items.iter().find(|c| c.label == label && c.kind == kind)
2686    }
2687
2688    #[test]
2689    fn type_annotation_suggests_builtins_surface_and_project_types() {
2690        let doc = "commons m {\n  type Order = { id: Int }\n}\n";
2691        let got = labels("  let x: ", doc);
2692        // Built-ins (with registry docs), the `bynk`-surface transparent types,
2693        // and the project's own type declaration.
2694        for want in ["Int", "Option", "Result", "Effect", "List", "Map"] {
2695            assert!(got.contains(&want.to_string()), "built-in {want}: {got:?}");
2696        }
2697        assert!(got.contains(&"Uuid".to_string()), "surface: {got:?}");
2698        assert!(got.contains(&"Order".to_string()), "project: {got:?}");
2699    }
2700
2701    #[test]
2702    fn return_type_and_type_args_are_type_positions() {
2703        assert!(is_type_position("  on call() -> "));
2704        assert!(is_type_position("  let x: Option["));
2705        assert!(is_type_position("  let x: Result[Int, "));
2706        // A partial type name being typed still counts.
2707        assert!(is_type_position("  -> Eff"));
2708    }
2709
2710    #[test]
2711    fn list_literal_is_not_a_type_position() {
2712        // A bare `[` opening a list literal is an expression, not type args…
2713        assert!(!is_type_position("  let xs = ["));
2714        // …so it is an expression position: a list element is a value, and the
2715        // constructor keywords are offered there (ADR 0093 D3) — not a
2716        // type-argument completion.
2717        let items = complete("  let xs = [", "context a.b\n", None);
2718        assert!(
2719            find(&items, "Some", CompletionKind::Constructor).is_some(),
2720            "{:?}",
2721            items.iter().map(|c| &c.label).collect::<Vec<_>>()
2722        );
2723    }
2724
2725    #[test]
2726    fn builtin_type_carries_its_registry_doc() {
2727        let items = complete("  let x: ", "context a.b\n", None);
2728        let int = find(&items, "Int", CompletionKind::Type).expect("Int present");
2729        assert_eq!(int.detail.as_deref(), keyword_doc("Int"));
2730        assert!(int.detail.is_some(), "Int should have a doc");
2731    }
2732
2733    #[test]
2734    fn keyword_position_suggests_keywords_and_snippets() {
2735        let items = complete("  ", "context a.b\n", None);
2736        // Declaration/statement keywords, with docs.
2737        assert!(find(&items, "capability", CompletionKind::Keyword).is_some());
2738        assert!(find(&items, "fn", CompletionKind::Keyword).is_some());
2739        assert!(find(&items, "let", CompletionKind::Keyword).is_some());
2740        // Uppercase type/value names are *not* keyword-position candidates.
2741        assert!(find(&items, "Int", CompletionKind::Keyword).is_none());
2742        assert!(find(&items, "Some", CompletionKind::Keyword).is_none());
2743        // Snippets are offered alongside.
2744        let snip = find(&items, "service", CompletionKind::Snippet).expect("service snippet");
2745        let body = snip.insert_text.as_deref().unwrap_or("");
2746        assert!(body.contains("on call"), "snippet body: {body:?}");
2747        assert!(body.contains("${1"), "snippet tab stop: {body:?}");
2748    }
2749
2750    #[test]
2751    fn keyword_position_fires_on_an_empty_line() {
2752        assert!(is_keyword_position(""));
2753        assert!(is_keyword_position("  cap"));
2754        assert!(!is_keyword_position("  let x ="));
2755        assert!(!is_keyword_position("  x: "));
2756        assert!(!complete("", "context a.b\n", None).is_empty());
2757    }
2758
2759    #[test]
2760    fn member_receiver_is_a_single_upper_ident_before_a_dot() {
2761        assert_eq!(member_receiver("  Color."), Some("Color".to_string()));
2762        assert_eq!(
2763            member_receiver("  let e = Email.o"),
2764            Some("Email".to_string())
2765        );
2766        assert_eq!(member_receiver("  x."), None); // lowercase = value receiver (slice 3)
2767        assert_eq!(member_receiver("  1."), None); // decimal literal, not a member access
2768        assert_eq!(member_receiver("  a.B."), None); // `.`-qualified segment
2769        assert_eq!(member_receiver("  Color"), None); // no dot yet
2770    }
2771
2772    #[test]
2773    fn receiver_extraction_survives_a_multibyte_char_before_the_receiver() {
2774        // A multi-byte non-identifier char immediately before the receiver used
2775        // to make the `i + 1` byte offset land mid-codepoint → panic on slice
2776        // (#715, hit on every completion/hover keystroke inside a string).
2777        assert_eq!(member_receiver("\"Foo."), Some("Foo".to_string()));
2778        assert_eq!(member_receiver("€Color."), Some("Color".to_string()));
2779        assert_eq!(member_receiver("—Bar."), Some("Bar".to_string()));
2780        assert_eq!(word_before_brace("\"cors {", 6), "cors");
2781        assert_eq!(
2782            record_construction_receiver("\"€Order {"),
2783            Some("Order".to_string())
2784        );
2785        // The reproduction from the issue: a completion request against a buffer
2786        // where the line prefix opens a string literal must not panic.
2787        let _ = complete("let x = \"Foo.", "commons m {}\n", None);
2788        let _ = complete("  \"€42.", "commons m {}\n", None);
2789        assert_eq!(
2790            value_receiver_rewrite("\"email.", 7).map(|(_, r)| r),
2791            Some(5),
2792        );
2793    }
2794
2795    #[test]
2796    fn sum_member_suggests_variants() {
2797        let doc = "commons m {\n  type Color = enum { Red, Green, Blue }\n}\n";
2798        let items = complete("  let c = Color.", doc, None);
2799        for v in ["Red", "Green", "Blue"] {
2800            assert!(
2801                find(&items, v, CompletionKind::Variant).is_some(),
2802                "variant {v}: {:?}",
2803                items.iter().map(|c| &c.label).collect::<Vec<_>>()
2804            );
2805        }
2806    }
2807
2808    #[test]
2809    fn refined_and_plain_alias_members_are_of_and_unsafe() {
2810        // A refinement-bearing type…
2811        let doc = "commons m {\n  type Email = String where NonEmpty\n}\n";
2812        let items = complete("  Email.", doc, None);
2813        assert!(find(&items, "of", CompletionKind::Member).is_some());
2814        assert!(find(&items, "unsafe", CompletionKind::Member).is_some());
2815        // …and a plain alias `type Id = Int` is *also* branded (the emitter
2816        // emits Id.of/Id.unsafe for every Refined body, refinement or not).
2817        let doc = "commons m {\n  type Id = Int\n}\n";
2818        assert!(find(&complete("  Id.", doc, None), "of", CompletionKind::Member).is_some());
2819    }
2820
2821    #[test]
2822    fn capability_member_suggests_ops() {
2823        let doc = "context a.b\n  capability Timer { fn now() -> Effect[Int]\n  fn at(t: Int) -> Effect[()] }\n";
2824        let items = complete("    Timer.", doc, None);
2825        let now = find(&items, "now", CompletionKind::Member).expect("`now` op offered");
2826        // Slice 5 detail polish: a typed signature (params + return), not bare
2827        // param names.
2828        assert_eq!(
2829            now.detail.as_deref(),
2830            Some("now() -> Effect[Int] — operation of `Timer`")
2831        );
2832        let at = find(&items, "at", CompletionKind::Member).expect("`at` op offered");
2833        assert_eq!(
2834            at.detail.as_deref(),
2835            Some("at(t: Int) -> Effect[()] — operation of `Timer`")
2836        );
2837    }
2838
2839    #[test]
2840    fn builtin_type_statics_are_offered() {
2841        assert!(
2842            find(
2843                &complete("  Int.", "context a.b\n", None),
2844                "parse",
2845                CompletionKind::Member
2846            )
2847            .is_some()
2848        );
2849        let j = complete("  Json.", "context a.b\n", None);
2850        assert!(find(&j, "encode", CompletionKind::Member).is_some());
2851        assert!(find(&j, "decode", CompletionKind::Member).is_some());
2852    }
2853
2854    #[test]
2855    fn builtin_sum_variants_are_complete() {
2856        // ADR 0093 D5/G3: every built-in sum variant in the AST registry must
2857        // surface on its name receiver. Registry-driven — adding an
2858        // `HttpResult`/`QueueResult` variant must appear in completion or this
2859        // fails (the standing drift guard, mirroring `kernel_registry`).
2860        let http: Vec<&str> = bynk_syntax::ast::HTTP_VARIANTS
2861            .iter()
2862            .map(|v| v.name)
2863            .collect();
2864        let queue: Vec<&str> = bynk_syntax::ast::QUEUE_VARIANTS
2865            .iter()
2866            .map(|v| v.name)
2867            .collect();
2868        for (recv, names) in [("HttpResult", http), ("QueueResult", queue)] {
2869            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2870            for name in names {
2871                assert!(
2872                    find(&items, name, CompletionKind::Variant).is_some(),
2873                    "{recv}.{name} missing: {:?}",
2874                    items.iter().map(|c| &c.label).collect::<Vec<_>>()
2875                );
2876            }
2877        }
2878    }
2879
2880    #[test]
2881    fn builtin_statics_are_reachable() {
2882        // ADR 0093 D5/G2: every BUILTIN_STATICS entry is reachable through the
2883        // name-receiver context — exercises the member_receiver→member_candidates
2884        // wiring for each receiver (e.g. that `Effect.`/`List.` are recognised).
2885        for &(recv, members) in BUILTIN_STATICS {
2886            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2887            for &(member, _) in members {
2888                assert!(
2889                    find(&items, member, CompletionKind::Member).is_some(),
2890                    "{recv}.{member} unreachable: {:?}",
2891                    items.iter().map(|c| &c.label).collect::<Vec<_>>()
2892                );
2893            }
2894        }
2895        // The slice-1 additions specifically — guards against a table regression
2896        // (the loop above can't catch an entry being deleted).
2897        for (recv, member) in [("List", "empty"), ("Map", "empty"), ("Effect", "pure")] {
2898            let items = complete(&format!("  {recv}."), "context a.b\n", None);
2899            assert!(
2900                find(&items, member, CompletionKind::Member).is_some(),
2901                "{recv}.{member} missing from the statics table"
2902            );
2903        }
2904    }
2905
2906    #[test]
2907    fn record_value_and_decimal_receivers_yield_nothing() {
2908        // A record type has no name-receiver members (fields are value-receiver).
2909        let doc = "commons m {\n  type Point = { x: Int }\n}\n";
2910        assert!(complete("  Point.", doc, None).is_empty(), "record");
2911        // A lowercase value receiver is deferred to slice 3.
2912        assert!(complete("  let p = q.", doc, None).is_empty(), "value");
2913        // A decimal literal is not a member access.
2914        assert!(complete("  let n = 1.", doc, None).is_empty(), "decimal");
2915    }
2916
2917    #[test]
2918    fn value_receiver_rewrite_drops_the_dot_for_lowercase_receivers() {
2919        let text = "  let x = email.\n";
2920        let offset = text.find('.').unwrap() + 1; // just after the dot
2921        let (rewritten, recv) = value_receiver_rewrite(text, offset).expect("value receiver");
2922        assert_eq!(
2923            rewritten, "  let x = email\n",
2924            "the trailing dot is dropped"
2925        );
2926        assert!(
2927            text.get(recv..=recv).is_some_and(|c| c == "l"),
2928            "the receiver offset lands inside `email`"
2929        );
2930        // A partial member is dropped too.
2931        let text2 = "  let x = email.ma\n";
2932        let off2 = text2.find(".ma").unwrap() + 3;
2933        assert_eq!(
2934            value_receiver_rewrite(text2, off2).map(|(r, _)| r),
2935            Some("  let x = email\n".to_string())
2936        );
2937        // Uppercase (name receiver, slice 2), decimal, and no-dot yield None.
2938        assert!(value_receiver_rewrite("  Email.", 8).is_none());
2939        assert!(value_receiver_rewrite("  let n = 1.", 12).is_none());
2940        assert!(value_receiver_rewrite("  email", 7).is_none());
2941    }
2942
2943    /// One table for every fixture in this module — `Types` is `Send + Sync`
2944    /// (T3.6b), so a `LazyLock` static gives the `TyId`s below something to
2945    /// resolve against without threading a value through each helper.
2946    static TYS: std::sync::LazyLock<Types> = std::sync::LazyLock::new(Types::new);
2947
2948    /// #2138: `run.` offers `id` in a case body, not once the case binds its
2949    /// own `run`.
2950    #[test]
2951    fn run_member_offers_id_in_a_case_body() {
2952        let labels = |text: &str| {
2953            let offset = text.find("run.").expect("run.") + "run.".len();
2954            let (rewritten, recv) = value_receiver_rewrite(text, offset).expect("a value receiver");
2955            run_id_member_candidates(&rewritten, recv)
2956                .into_iter()
2957                .map(|c| c.label)
2958                .collect::<Vec<_>>()
2959        };
2960        assert_eq!(
2961            labels("suite rid.orders {\n  case \"a\" {\n    expect run. == \"local\"\n  }\n}\n"),
2962            ["id"]
2963        );
2964        assert!(
2965            labels("suite rid.orders {\n  case \"a\" {\n    let run = 1\n    expect run. == 1\n  }\n}\n")
2966                .is_empty()
2967        );
2968    }
2969
2970    #[test]
2971    fn value_member_candidates_lists_kernel_methods() {
2972        use bynk_syntax::ast::BaseType;
2973        let list = TYS.intern(Ty::List(TYS.intern(Ty::Base(BaseType::Int))));
2974        let items = value_member_candidates(list, &TYS, "context a.b\n", None);
2975        assert!(find(&items, "fold", CompletionKind::Member).is_some());
2976        assert!(find(&items, "get", CompletionKind::Member).is_some());
2977
2978        let string = TYS.intern(Ty::Base(BaseType::String));
2979        let items = value_member_candidates(string, &TYS, "context a.b\n", None);
2980        assert!(find(&items, "split", CompletionKind::Member).is_some());
2981        assert!(find(&items, "trim", CompletionKind::Member).is_some());
2982    }
2983
2984    #[test]
2985    fn value_member_candidates_lists_refined_inherited_kernel_methods() {
2986        // #561: a refined receiver offers its base type's read-only kernel
2987        // methods in `.`-member completion.
2988        use bynk_check::checker::NamedKind;
2989        use bynk_syntax::ast::BaseType;
2990        let name = TYS.intern(Ty::Named {
2991            name: "Name".to_string(),
2992            kind: NamedKind::Refined(BaseType::String),
2993            args: Vec::new(),
2994        });
2995        let items = value_member_candidates(
2996            name,
2997            &TYS,
2998            "commons m {\n  type Name = String where NonEmpty\n}\n",
2999            None,
3000        );
3001        assert!(find(&items, "toUpper", CompletionKind::Member).is_some());
3002        assert!(find(&items, "length", CompletionKind::Member).is_some());
3003    }
3004
3005    #[test]
3006    fn expression_position_offers_locals() {
3007        // Value-expecting positions (locals offered).
3008        assert!(is_expression_position("  let y = "));
3009        assert!(is_expression_position("  let y = a + lo")); // after a binary op
3010        assert!(is_expression_position("  f("));
3011        assert!(is_expression_position("  g(a, "));
3012        assert!(is_expression_position("  xs.fold(0, (acc, x) => ac")); // lambda body
3013        // `let y = foo` is still a value position (you're typing the value).
3014        assert!(is_expression_position("  let y = foo"));
3015        // Not value positions.
3016        assert!(!is_expression_position("  let y: ")); // type annotation
3017        assert!(!is_expression_position("  on call() -> ")); // return type
3018        assert!(!is_expression_position("  tot")); // bare line start (keyword position covers it)
3019    }
3020
3021    #[test]
3022    fn value_member_candidates_lists_record_fields() {
3023        use bynk_check::checker::NamedKind;
3024        let order = TYS.intern(Ty::Named {
3025            name: "Order".to_string(),
3026            kind: NamedKind::Record,
3027            args: Vec::new(),
3028        });
3029        let doc = "commons m {\n  type Order = { id: Int, total: Int }\n}\n";
3030        let items = value_member_candidates(order, &TYS, doc, None);
3031        assert!(
3032            find(&items, "id", CompletionKind::Field).is_some(),
3033            "{items:?}",
3034            items = items.iter().map(|c| &c.label).collect::<Vec<_>>()
3035        );
3036        assert!(find(&items, "total", CompletionKind::Field).is_some());
3037    }
3038
3039    #[test]
3040    fn store_field_member_candidates_offers_entry_ops_and_query_accessors() {
3041        // #596: a bare `store Map` field receiver offers its entry ops
3042        // (put/get/…) AND the `.entries`/`.keys`/`.values` accessors — the
3043        // vocabulary `value_member_candidates` alone can't see, since the
3044        // checker types a bare store map as plain `Ty::Query` (ADR 0120).
3045        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";
3046        let offset = doc.find("items.").unwrap() + "items.".len();
3047        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
3048        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
3049        assert!(
3050            find(&items, "put", CompletionKind::Member).is_some(),
3051            "{items:?}",
3052            items = items.iter().map(|c| &c.label).collect::<Vec<_>>()
3053        );
3054        assert!(find(&items, "get", CompletionKind::Member).is_some());
3055        assert!(find(&items, "update", CompletionKind::Member).is_some());
3056        assert!(find(&items, "entries", CompletionKind::Field).is_some());
3057        assert!(find(&items, "keys", CompletionKind::Field).is_some());
3058        assert!(find(&items, "values", CompletionKind::Field).is_some());
3059    }
3060
3061    #[test]
3062    fn store_field_member_candidates_empty_for_ordinary_local() {
3063        // An ordinary local named the same as no store field yields nothing —
3064        // the receiver's provenance, not its name alone, drives this path.
3065        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";
3066        let offset = doc.rfind("items.").unwrap() + "items.".len();
3067        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
3068        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
3069        assert!(items.is_empty());
3070    }
3071
3072    #[test]
3073    fn store_field_member_candidates_skips_query_accessors_on_held_map() {
3074        // ADR 0184: a held `Map[K, Connection]` never offers the key-query
3075        // accessors, only its entry ops (`put`/`get`/…).
3076        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";
3077        let offset = doc.find("conns.").unwrap() + "conns.".len();
3078        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
3079        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
3080        assert!(find(&items, "put", CompletionKind::Member).is_some());
3081        assert!(find(&items, "entries", CompletionKind::Field).is_none());
3082        assert!(find(&items, "keys", CompletionKind::Field).is_none());
3083        assert!(find(&items, "values", CompletionKind::Field).is_none());
3084    }
3085
3086    #[test]
3087    fn store_field_member_candidates_offers_set_and_cache_vocabularies() {
3088        // Not every store kind gets the Map-only accessors, but every kind
3089        // gets its own entry ops.
3090        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";
3091        let offset = doc.find("tags.").unwrap() + "tags.".len();
3092        let (rewritten, recv_offset) = value_receiver_rewrite(doc, offset).expect("bare receiver");
3093        let items = store_field_member_candidates(&rewritten, recv_offset, &[]);
3094        assert!(find(&items, "add", CompletionKind::Member).is_some());
3095        assert!(find(&items, "entries", CompletionKind::Field).is_none());
3096    }
3097
3098    // -- v0.124 (slice 3): the non-keyword completion contexts --
3099
3100    #[test]
3101    fn record_construction_offers_field_names() {
3102        let doc = "commons m {\n  type Order = { id: Int, total: Int }\n}\n";
3103        let got = labels("  let o = Order { ", doc);
3104        assert!(got.contains(&"id".to_string()), "{got:?}");
3105        assert!(got.contains(&"total".to_string()), "{got:?}");
3106        // After a comma, still field-name position.
3107        let got2 = labels("  let o = Order { id: 1, ", doc);
3108        assert!(got2.contains(&"total".to_string()), "{got2:?}");
3109        // After a `:`, it is a field *type* position, not a field name.
3110        assert!(record_construction_receiver("  let o = Order { id: ").is_none());
3111        // A lowercase brace context (a block) is not a construction.
3112        assert!(record_construction_receiver("  if x { ").is_none());
3113    }
3114
3115    #[test]
3116    fn from_offers_protocols() {
3117        let got = labels("  service s from ", "context a.b\n");
3118        assert!(got.contains(&"http".to_string()), "{got:?}");
3119        assert!(got.contains(&"cron".to_string()) && got.contains(&"queue".to_string()));
3120    }
3121
3122    #[test]
3123    fn on_offers_handler_kinds() {
3124        let got = labels("  on ", "context a.b\n");
3125        assert!(got.contains(&"call".to_string()), "{got:?}");
3126        assert!(got.contains(&"GET".to_string()) && got.contains(&"schedule".to_string()));
3127    }
3128
3129    #[test]
3130    fn by_offers_project_actors() {
3131        let doc = "context a.b\n\nactor Caller { auth = Bearer }\n";
3132        let got = labels("    by ", doc);
3133        assert!(got.contains(&"Caller".to_string()), "{got:?}");
3134    }
3135
3136    #[test]
3137    fn exports_offers_export_kinds() {
3138        let got = labels("  exports ", "adapter t {\n  binding \"./b.ts\"\n}\n");
3139        assert!(got.contains(&"capability".to_string()), "{got:?}");
3140        assert!(got.contains(&"transparent".to_string()));
3141    }
3142
3143    #[test]
3144    fn provides_offers_in_scope_capabilities() {
3145        let doc = "context a.b\n\ncapability Store { fn get() -> Effect[Int] }\n";
3146        let got = labels("  provides ", doc);
3147        assert!(got.contains(&"Store".to_string()), "{got:?}");
3148    }
3149
3150    #[test]
3151    fn where_offers_predicate_names() {
3152        // Type-decl refinement.
3153        let got = labels("  type Code = Int where ", "context a.b\n");
3154        assert!(got.contains(&"InRange".to_string()), "{got:?}");
3155        assert!(got.contains(&"NonNegative".to_string()));
3156        // #472: a match arm's `_ where <predicate>` shares the same catalogue.
3157        let got = labels("      _ where ", "context a.b\n");
3158        assert!(got.contains(&"Matches".to_string()), "{got:?}");
3159        assert!(got.contains(&"NonEmpty".to_string()));
3160    }
3161
3162    #[test]
3163    fn is_for_all_where_matches_only_the_for_all_binder() {
3164        assert!(is_for_all_where("\tfor all x: Int, y: Int where "));
3165        assert!(is_for_all_where("for all x: Int where "));
3166        // `for`/`all` must be standalone words, not prefixes of longer
3167        // identifiers.
3168        assert!(!is_for_all_where("format(x) where "));
3169        assert!(!is_for_all_where("for allocate x where "));
3170        // A type-decl or match-arm `where` isn't a `for all` binder.
3171        assert!(!is_for_all_where("  type Code = Int where "));
3172        assert!(!is_for_all_where("      _ where "));
3173        assert!(!is_for_all_where("no where here"));
3174    }
3175
3176    #[test]
3177    fn for_all_where_falls_through_to_expression_position_not_predicates() {
3178        // #472 (finding 2, PR #827 review): a `for all` binder's `where`
3179        // clause takes an arbitrary `Bool`, not the predicate catalogue —
3180        // it must not offer `InRange`/`NonNegative`/etc.
3181        let got = labels("\tfor all x: Int where ", "context a.b\n");
3182        assert!(!got.contains(&"InRange".to_string()), "{got:?}");
3183        assert!(!got.contains(&"NonNegative".to_string()), "{got:?}");
3184    }
3185
3186    #[test]
3187    fn clause_detectors_do_not_over_fire() {
3188        // `on` inside a larger word, and a field named `from`, must not trigger.
3189        assert!(!after_clause_keyword("  session ", "on"));
3190        assert!(!after_clause_keyword("  let from = ", "from"));
3191        // A standalone keyword does.
3192        assert!(after_clause_keyword("  service s from ", "from"));
3193        assert!(after_clause_keyword("    by ", "by"));
3194    }
3195
3196    #[test]
3197    fn contract_clause_kind_detects_requires_and_ensures() {
3198        assert_eq!(contract_clause_kind("  requires positive: "), Some(false));
3199        assert_eq!(contract_clause_kind("  ensures never_neg: "), Some(true));
3200        // Not a contract clause.
3201        assert_eq!(contract_clause_kind("  id: Int"), None);
3202        assert_eq!(contract_clause_kind("  let x = 1"), None);
3203    }
3204
3205    #[test]
3206    fn cors_field_position_inside_cors_block() {
3207        // Cursor at a fresh field-name line inside a `cors { }` block.
3208        let doc = "service api from http {\n  cors {\n    ";
3209        assert!(in_cors_field_position(doc, doc.len()));
3210        // After a `:` it is a value position, not a field-name position.
3211        let doc2 = "service api from http {\n  cors {\n    origins: ";
3212        assert!(!in_cors_field_position(doc2, doc2.len()));
3213        // Not inside a `cors` block (an ordinary record construction) — no.
3214        let doc3 = "let x = Order {\n    ";
3215        assert!(!in_cors_field_position(doc3, doc3.len()));
3216    }
3217
3218    #[test]
3219    fn security_field_position_inside_security_block() {
3220        // Cursor at a fresh field-name line inside a `security { }` block.
3221        let doc = "service api from http {\n  security {\n    ";
3222        assert!(in_security_field_position(doc, doc.len()));
3223        // After a `:` it is a value position, not a field-name position.
3224        let doc2 = "service api from http {\n  security {\n    hsts: ";
3225        assert!(!in_security_field_position(doc2, doc2.len()));
3226        // A `cors { }` block is not a `security` block.
3227        let doc3 = "service api from http {\n  cors {\n    ";
3228        assert!(!in_security_field_position(doc3, doc3.len()));
3229    }
3230
3231    /// Non-ASCII text inside the block (a `-- café` comment) must not break
3232    /// the field-position probes; a mid-codepoint offset degrades to `false`
3233    /// instead of panicking the request handler.
3234    #[test]
3235    fn field_position_probes_survive_non_ascii() {
3236        let doc = "service api from http {\n  cors { // café\n    ";
3237        assert!(in_cors_field_position(doc, doc.len()));
3238        // A raw byte offset that lands inside the `é` must not panic.
3239        let mid = doc.find('é').unwrap() + 1;
3240        assert!(!doc.is_char_boundary(mid));
3241        let _ = in_cors_field_position(doc, mid);
3242        let _ = in_security_field_position(doc, mid);
3243        let _ = in_limits_field_position(doc, mid);
3244    }
3245
3246    #[test]
3247    fn by_in_a_suite_offers_the_reserved_principals() {
3248        // #2135: `Nobody`, `Forged` and `Stale` after `by` in a suite only.
3249        let suite = "suite pay.hooks as system {\n  case \"c\" {\n    let r <- webhooks.POST(\"/h\", \"x\") by ";
3250        let labels: Vec<String> =
3251            complete("    let r <- webhooks.POST(\"/h\", \"x\") by ", suite, None)
3252                .into_iter()
3253                .map(|c| c.label)
3254                .collect();
3255        for name in ["Nobody", "Forged", "Stale"] {
3256            assert!(labels.iter().any(|l| l == name), "{name}: {labels:?}");
3257        }
3258        let context = "context pay.hooks\n\nservice api from http {\n  on GET(\"/a\") () -> Effect[HttpResult[String]] by ";
3259        let labels: Vec<String> = complete(
3260            "  on GET(\"/a\") () -> Effect[HttpResult[String]] by ",
3261            context,
3262            None,
3263        )
3264        .into_iter()
3265        .map(|c| c.label)
3266        .collect();
3267        assert!(!labels.iter().any(|l| l == "Forged"), "{labels:?}");
3268    }
3269
3270    #[test]
3271    fn env_name_position_and_candidates() {
3272        // #2134: a fresh entry line inside a suite's `env { }` block.
3273        let doc = "suite shop.pay {\n  env {\n    ";
3274        assert!(in_env_name_position(doc, doc.len()));
3275        // After `=` it is a value position.
3276        let doc2 = "suite shop.pay {\n  env {\n    A = ";
3277        assert!(!in_env_name_position(doc2, doc2.len()));
3278        // A case body is not an `env` block.
3279        let doc3 = "suite shop.pay {\n  case \"c\" {\n    ";
3280        assert!(!in_env_name_position(doc3, doc3.len()));
3281        let mut files = HashMap::new();
3282        files.insert(
3283            PathBuf::from("src/pay.bynk"),
3284            "actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = String }\n\
3285             let p <- Secrets.get(\"PAYMENTS_PROCESSOR\")\n"
3286                .to_string(),
3287        );
3288        let labels: Vec<String> = env_name_candidates(doc, Some(&files))
3289            .into_iter()
3290            .map(|c| c.label)
3291            .collect();
3292        assert_eq!(labels, ["AUTH_JWT_SECRET", "PAYMENTS_PROCESSOR"]);
3293    }
3294
3295    #[test]
3296    fn limits_field_position_inside_limits_block() {
3297        // Cursor at a fresh field-name line inside a `limits { }` block.
3298        let doc = "service api from http {\n  limits {\n    ";
3299        assert!(in_limits_field_position(doc, doc.len()));
3300        // After a `:` it is a value position, not a field-name position.
3301        let doc2 = "service api from http {\n  limits {\n    maxBody: ";
3302        assert!(!in_limits_field_position(doc2, doc2.len()));
3303        // A `security { }` block is not a `limits` block.
3304        let doc3 = "service api from http {\n  security {\n    ";
3305        assert!(!in_limits_field_position(doc3, doc3.len()));
3306    }
3307
3308    #[test]
3309    fn service_body_item_position_offers_cors() {
3310        // A bare-word item start inside a `service … {` block.
3311        let doc = "service api from http {\n  ";
3312        assert!(in_service_body_item_position(doc, doc.len(), "  "));
3313        // Inside a nested `cors { }` block the innermost brace is not the
3314        // service brace, so the service-item cell does not fire.
3315        let doc2 = "service api from http {\n  cors {\n    ";
3316        assert!(!in_service_body_item_position(doc2, doc2.len(), "    "));
3317    }
3318
3319    #[test]
3320    fn cache_arg_position_inside_cache_annotation() {
3321        // Cursor at a fresh argument-name position inside `@cache( … )`.
3322        let doc = "service api from http {\n  @cache(";
3323        assert!(in_cache_arg_position(doc, doc.len()));
3324        // After a first arg + comma, still an argument-name position.
3325        let doc2 = "service api from http {\n  @cache(maxAge: 5.minutes, ";
3326        assert!(in_cache_arg_position(doc2, doc2.len()));
3327        // After a `:` it is a value position, not an argument-name position.
3328        let doc3 = "service api from http {\n  @cache(maxAge: ";
3329        assert!(!in_cache_arg_position(doc3, doc3.len()));
3330        // A bare `cache(` call (no `@`) is not the annotation.
3331        let doc4 = "let x = cache(";
3332        assert!(!in_cache_arg_position(doc4, doc4.len()));
3333        // An ordinary handler param list is not a `@cache` position.
3334        let doc5 = "on GET(\"/x\") by v: Visitor (";
3335        assert!(!in_cache_arg_position(doc5, doc5.len()));
3336    }
3337
3338    #[test]
3339    fn a_capability_declaration_completes_its_base_then_its_options() {
3340        // P3 (#2342): the instantiable kinds after `=`, narrowed as typed.
3341        let bases: Vec<String> = instance_declaration_completions("capability LoginLimit = ")
3342            .into_iter()
3343            .map(|(label, ..)| label)
3344            .collect();
3345        assert_eq!(bases, ["Kv", "Service", "R2", "RateLimit", "EdgeCache"]);
3346        let narrowed: Vec<String> = instance_declaration_completions("capability L = Ra")
3347            .into_iter()
3348            .map(|(label, ..)| label)
3349            .collect();
3350        assert_eq!(narrowed, ["RateLimit"]);
3351        // Then the required options, as one snippet.
3352        let options = instance_declaration_completions("capability LoginLimit = RateLimit ");
3353        assert_eq!(options.len(), 1);
3354        assert_eq!(options[0].0, "@limit(…) @period(…)");
3355        assert_eq!(options[0].2, "@limit(${1:100}) @period(${2:60.seconds})");
3356        // A kind with no required option, or a line already carrying them,
3357        // gets nothing; nor does any other line.
3358        assert!(instance_declaration_completions("capability A = R2 ").is_empty());
3359        assert!(instance_declaration_completions("capability Edge = EdgeCache ").is_empty());
3360        assert!(instance_declaration_completions("capability L = RateLimit @limit(1) ").is_empty());
3361        assert!(instance_declaration_completions("let x = R").is_empty());
3362    }
3363
3364    #[test]
3365    fn a_rate_limit_instance_option_is_not_the_limit_annotation() {
3366        // P3 (#2342): `@limit(` on a capability declaration is an instance option.
3367        let doc = "context a\ncapability LoginLimit = RateLimit @limit(";
3368        assert!(!in_limit_arg_position(doc, doc.len()));
3369        // `@period(` offers the platform's two windows, and only at its value.
3370        let doc = "context a\ncapability LoginLimit = RateLimit @limit(100) @period(";
3371        let offered: Vec<&str> = instance_option_values(doc, doc.len())
3372            .iter()
3373            .map(|(v, _)| *v)
3374            .collect();
3375        assert_eq!(offered, ["10.seconds", "60.seconds"]);
3376        assert!(instance_option_values("let x = period(", 15).is_empty());
3377        let doc = "service api from http {\n  @period(";
3378        assert!(instance_option_values(doc, doc.len()).is_empty());
3379    }
3380
3381    #[test]
3382    fn limit_arg_position_inside_limit_annotation() {
3383        // Cursor at a fresh argument-name position inside `@limit( … )`.
3384        let doc = "service api from http {\n  @limit(";
3385        assert!(in_limit_arg_position(doc, doc.len()));
3386        // After a first arg + comma, still an argument-name position.
3387        let doc2 = "service api from http {\n  @limit(maxBody: 1048576, ";
3388        assert!(in_limit_arg_position(doc2, doc2.len()));
3389        // After a `:` it is a value position, not an argument-name position.
3390        let doc3 = "service api from http {\n  @limit(maxBody: ";
3391        assert!(!in_limit_arg_position(doc3, doc3.len()));
3392        // A bare `limit(` call (no `@`) is not the annotation.
3393        let doc4 = "let x = limit(";
3394        assert!(!in_limit_arg_position(doc4, doc4.len()));
3395        // An ordinary handler param list is not a `@limit` position.
3396        let doc5 = "on GET(\"/x\") by v: Visitor (";
3397        assert!(!in_limit_arg_position(doc5, doc5.len()));
3398    }
3399
3400    #[test]
3401    fn raw_arg_position_inside_raw_annotation() {
3402        let doc = "service api from http {\n  @raw(";
3403        assert!(in_raw_arg_position(doc, doc.len()));
3404        let doc2 = "service api from http {\n  @raw(contentType: ";
3405        assert!(!in_raw_arg_position(doc2, doc2.len()));
3406        let doc3 = "let x = raw(";
3407        assert!(!in_raw_arg_position(doc3, doc3.len()));
3408        let doc4 = "service api from http {\n  @limit(";
3409        assert!(!in_raw_arg_position(doc4, doc4.len()));
3410    }
3411
3412    #[test]
3413    fn sum_type_variants_lists_variants() {
3414        let doc = "commons m {\n  type Status = enum { Pending, Shipped }\n}\n";
3415        let got: Vec<String> = sum_type_variants("Status", doc, None)
3416            .into_iter()
3417            .map(|c| c.label)
3418            .collect();
3419        assert!(got.contains(&"Pending".to_string()), "{got:?}");
3420        assert!(got.contains(&"Shipped".to_string()), "{got:?}");
3421    }
3422
3423    /// #1888: a field's and a variant's doc ride on their completion items;
3424    /// an undocumented one has none.
3425    /// Review of #1968: completion reads unchecked source, so a derivation
3426    /// cycle completes nothing rather than recursing without end.
3427    #[test]
3428    fn a_derivation_cycle_completes_nothing() {
3429        let doc = "context a\n\ncapability A = B\ncapability B = C\ncapability C = A\n";
3430        assert!(member_candidates("A", doc, None).is_empty());
3431    }
3432
3433    /// S4a (#1966): a derived capability completes its base's operations.
3434    #[test]
3435    fn a_derived_capability_completes_its_bases_operations() {
3436        const DERIVED_DOC: &str =
3437            "context a\n\nconsumes bynk.cloudflare { Kv }\n\ncapability Sessions = Kv\n";
3438        let labels: Vec<String> = member_candidates("Sessions", DERIVED_DOC, None)
3439            .into_iter()
3440            .map(|c| c.label)
3441            .collect();
3442        for op in ["get", "put", "putTtl", "delete", "list"] {
3443            assert!(labels.iter().any(|l| l == op), "{op} missing: {labels:?}");
3444        }
3445    }
3446
3447    #[test]
3448    fn member_completions_carry_their_docs() {
3449        let doc = "commons m\n\ntype Rec = {\n  --- The id.\n  id: Int,\n  n: Int,\n}\n\ntype Status = enum {\n  ---\n  Waiting.\n  ---\n  Pending,\n  Shipped,\n}\n";
3450        let docs = |items: Vec<Completion>| -> Vec<(String, Option<String>)> {
3451            items
3452                .into_iter()
3453                .map(|c| (c.label, c.documentation.map(|d| d.trim_end().to_string())))
3454                .collect()
3455        };
3456        assert_eq!(
3457            docs(record_field_names("Rec", doc, None)),
3458            [("id".into(), Some("The id.".into())), ("n".into(), None)]
3459        );
3460        assert_eq!(
3461            docs(sum_type_variants("Status", doc, None)),
3462            [
3463                ("Pending".into(), Some("Waiting.".into())),
3464                ("Shipped".into(), None)
3465            ]
3466        );
3467        assert_eq!(
3468            docs(member_candidates("Status", doc, None))
3469                .into_iter()
3470                .filter(|(l, _)| l == "Pending")
3471                .collect::<Vec<_>>(),
3472            [("Pending".into(), Some("Waiting.".into()))]
3473        );
3474    }
3475
3476    #[test]
3477    fn variants_for_ty_offers_built_in_result_and_option() {
3478        // v0.145 (ADR 0169, base gap): a `Result`/`Option` scrutinee offers its
3479        // built-in variants, which are not declared types (`sum_type_variants`
3480        // can't see them). This is what makes match-arm completion fire for a
3481        // Result/Option scrutinee at all.
3482        use bynk_syntax::ast::BaseType;
3483        let result = TYS.intern(Ty::Result(
3484            TYS.intern(Ty::Base(BaseType::Int)),
3485            TYS.intern(Ty::Base(BaseType::String)),
3486        ));
3487        let got: Vec<String> = variants_for_ty(result, &TYS, "", None)
3488            .into_iter()
3489            .map(|c| c.label)
3490            .collect();
3491        assert_eq!(got, vec!["Ok".to_string(), "Err".to_string()]);
3492
3493        let option = TYS.intern(Ty::Option(TYS.intern(Ty::Base(BaseType::Int))));
3494        let got: Vec<String> = variants_for_ty(option, &TYS, "", None)
3495            .into_iter()
3496            .map(|c| c.label)
3497            .collect();
3498        assert_eq!(got, vec!["Some".to_string(), "None".to_string()]);
3499    }
3500
3501    #[test]
3502    fn nested_variant_completions_resolves_the_payload_type() {
3503        use bynk_check::checker::NamedKind;
3504        use bynk_syntax::ast::BaseType;
3505        // Built-in outer: `Some(‸)` on `Option[Result[Int, E]]` offers the
3506        // payload `Result`'s variants.
3507        let payload = TYS.intern(Ty::Result(
3508            TYS.intern(Ty::Base(BaseType::Int)),
3509            TYS.intern(Ty::Named {
3510                name: "E".to_string(),
3511                kind: NamedKind::Sum,
3512                args: Vec::new(),
3513            }),
3514        ));
3515        let scrut = TYS.intern(Ty::Option(payload));
3516        let got: Vec<String> = nested_variant_completions(scrut, &TYS, "Some", "", None)
3517            .into_iter()
3518            .map(|c| c.label)
3519            .collect();
3520        assert_eq!(got, vec!["Ok".to_string(), "Err".to_string()]);
3521
3522        // The other outer variant of a Result payload resolves the error arm.
3523        let opt_int = TYS.intern(Ty::Option(TYS.intern(Ty::Base(BaseType::Int))));
3524        let inner = TYS.intern(Ty::Result(TYS.intern(Ty::Base(BaseType::Int)), opt_int));
3525        let got: Vec<String> = nested_variant_completions(inner, &TYS, "Err", "", None)
3526            .into_iter()
3527            .map(|c| c.label)
3528            .collect();
3529        assert_eq!(got, vec!["Some".to_string(), "None".to_string()]);
3530
3531        // User-sum outer: `Wrap(‸)` on a declared sum offers the payload field
3532        // type's variants, walked from source.
3533        let doc = "commons m {\n  type Inner = enum { A, B }\n  \
3534                   type Outer = | Wrap(inner: Inner) | Bare\n}\n";
3535        let outer = TYS.intern(Ty::Named {
3536            name: "Outer".to_string(),
3537            kind: NamedKind::Sum,
3538            args: Vec::new(),
3539        });
3540        let got: Vec<String> = nested_variant_completions(outer, &TYS, "Wrap", doc, None)
3541            .into_iter()
3542            .map(|c| c.label)
3543            .collect();
3544        assert!(got.contains(&"A".to_string()), "{got:?}");
3545        assert!(got.contains(&"B".to_string()), "{got:?}");
3546    }
3547
3548    #[test]
3549    fn literal_kind_scrutinee_suggests_no_variants() {
3550        // v0.130: a literal-kind `match` (a primitive `Int`/`String`/`Bool`
3551        // scrutinee, or a refinement over one) has no variant names to offer.
3552        // A primitive types to `Ty::Base`, which never reaches this lookup; a
3553        // refined scrutinee types to `Ty::Named` but has no `enum` body, so the
3554        // variant candidate set is empty — no bogus completions.
3555        let doc = "commons m {\n  type Quantity = Int where InRange(1, 99)\n}\n";
3556        assert!(sum_type_variants("Quantity", doc, None).is_empty());
3557        assert!(sum_type_variants("Int", doc, None).is_empty());
3558    }
3559
3560    // -- parse cache (#733) --
3561
3562    /// Collect the enumerated unit names for a buffer + project file set.
3563    fn enumerated_units(doc_text: &str, files: Option<&HashMap<PathBuf, String>>) -> Vec<String> {
3564        let mut names = Vec::new();
3565        for_each_unit(doc_text, files, |u| names.push(u.name().joined()));
3566        names
3567    }
3568
3569    /// Content-ownership track (#1086) slice 0+1: `files` is now a pre-read
3570    /// `(path, content)` map — the caller's job (`bynk-lsp`'s overlay-then-disk
3571    /// sweep) — rather than a bare path list `cached_project_unit` used to
3572    /// read from disk itself. These tests build that map directly; none of
3573    /// them touch the real filesystem any more.
3574    fn synthetic_path(name: &str) -> PathBuf {
3575        PathBuf::from(format!("/synthetic/{name}.bynk"))
3576    }
3577
3578    #[test]
3579    fn for_each_unit_yields_embedded_buffer_and_project_files() {
3580        let sibling = synthetic_path("sibling");
3581        let files = HashMap::from([(
3582            sibling,
3583            "commons proj.sibling {\n  fn s() -> Int { 1 }\n}\n".to_string(),
3584        )]);
3585
3586        let names = enumerated_units(
3587            "commons proj.buffer {\n  fn b() -> Int { 1 }\n}\n",
3588            Some(&files),
3589        );
3590        // The embedded `bynk` surface, the live buffer, and the project file all show.
3591        assert!(names.iter().any(|n| n == "bynk"), "embedded: {names:?}");
3592        assert!(
3593            names.iter().any(|n| n == "proj.buffer"),
3594            "buffer: {names:?}"
3595        );
3596        assert!(
3597            names.iter().any(|n| n == "proj.sibling"),
3598            "project file: {names:?}"
3599        );
3600    }
3601
3602    /// Finding #62: when the buffer and a project file both declare a type
3603    /// under the same name — the buffer's stale copy, or two distinct files
3604    /// that happen to collide — `record_field_names` and `sum_type_variants`
3605    /// must take the first match (the buffer, since `for_each_unit` yields it
3606    /// before `files`) rather than unioning both, so a field/variant removed
3607    /// from the live buffer does not resurface from the stale copy.
3608    #[test]
3609    fn same_named_declarations_do_not_union_across_units() {
3610        let stale = HashMap::from([(
3611            synthetic_path("stale"),
3612            "commons m {\n  \
3613             type Rec = { total: Int, old_field: Int }\n  \
3614             type Status = enum { Pending, Retired }\n\
3615             }\n"
3616            .to_string(),
3617        )]);
3618
3619        let buffer = "commons m {\n  \
3620                       type Rec = { total: Int }\n  \
3621                       type Status = enum { Pending, Shipped }\n\
3622                       }\n";
3623        let files = Some(&stale);
3624
3625        let fields: Vec<String> = record_field_names("Rec", buffer, files)
3626            .into_iter()
3627            .map(|c| c.label)
3628            .collect();
3629        assert_eq!(fields, vec!["total".to_string()], "{fields:?}");
3630
3631        let variants: Vec<String> = sum_type_variants("Status", buffer, files)
3632            .into_iter()
3633            .map(|c| c.label)
3634            .collect();
3635        assert_eq!(
3636            variants,
3637            vec!["Pending".to_string(), "Shipped".to_string()],
3638            "{variants:?}"
3639        );
3640    }
3641
3642    /// Finding #62: `capabilities_of_unit` takes the first unit named `unit`
3643    /// rather than unioning exports across every matching unit — the same
3644    /// buffer-vs-stale-copy reasoning as
3645    /// `same_named_declarations_do_not_union_across_units`.
3646    #[test]
3647    fn capabilities_of_unit_does_not_union_across_units() {
3648        let stale = HashMap::from([(
3649            synthetic_path("stale"),
3650            "adapter tokens {\n  \
3651             exports capability { Jwt, Retired }\n\
3652             }\n"
3653            .to_string(),
3654        )]);
3655
3656        let buffer = "adapter tokens {\n  exports capability { Jwt }\n}\n";
3657        let files = Some(&stale);
3658
3659        let caps = capabilities_of_unit("tokens", buffer, &split_dependency_files(files, None));
3660        assert_eq!(caps, vec!["Jwt".to_string()], "{caps:?}");
3661    }
3662
3663    #[test]
3664    fn project_unit_cache_invalidates_on_change() {
3665        let path = synthetic_path("unit");
3666
3667        let first_files = HashMap::from([(
3668            path.clone(),
3669            "commons proj.first {\n  fn a() -> Int { 1 }\n}\n".to_string(),
3670        )]);
3671        let first = enumerated_units("context a.b\n", Some(&first_files));
3672        assert!(
3673            first.iter().any(|n| n == "proj.first"),
3674            "first read: {first:?}"
3675        );
3676
3677        // Same path, different content — the cache is keyed on content
3678        // equality (not disk metadata, since content is supplied directly
3679        // and an open-buffer overlay has no meaningful mtime at all), so it
3680        // must serve the new parse, not the stale one.
3681        let second_files = HashMap::from([(
3682            path,
3683            "commons proj.second.longer {\n  fn a() -> Int { 1 }\n  fn c() -> Int { 3 }\n}\n"
3684                .to_string(),
3685        )]);
3686        let second = enumerated_units("context a.b\n", Some(&second_files));
3687        assert!(
3688            second.iter().any(|n| n == "proj.second.longer"),
3689            "after change: {second:?}"
3690        );
3691        assert!(
3692            !second.iter().any(|n| n == "proj.first"),
3693            "stale served: {second:?}"
3694        );
3695    }
3696
3697    #[test]
3698    fn unparseable_project_file_is_skipped() {
3699        // Content-ownership track (#1086) slice 0+1: `cached_project_unit` no
3700        // longer reads disk itself, so there is no more "path doesn't exist on
3701        // disk" case to skip — the equivalent today is content that fails to
3702        // parse (the caller's sweep omits an unreadable file from the map
3703        // entirely, so this covers the other unusable case: present but not
3704        // parseable).
3705        let files = HashMap::from([(synthetic_path("broken"), "not bynk source {{{".to_string())]);
3706        let names = enumerated_units("context a.b\n", Some(&files));
3707        let baseline = enumerated_units("context a.b\n", None);
3708        // No panic, and the broken file contributes nothing beyond the same
3709        // embedded surface + buffer a project with no files at all yields —
3710        // not just "the embedded surface is present somewhere", which would
3711        // pass even if a future recovery-parse change made the broken
3712        // content yield a spurious unit alongside it.
3713        assert_eq!(names, baseline, "{names:?}");
3714    }
3715}